Add application badge with number of new notifications

This commit is contained in:
Marcin Czachurski 2023-10-24 14:04:23 +02:00
parent 1af66b189d
commit db407195f2
15 changed files with 269 additions and 40 deletions

View File

@ -9,33 +9,34 @@ import SwiftData
import EnvironmentKit
@Model final public class ApplicationSettings {
public var currentAccount: String?
public var theme: Int32
public var tintColor: Int32
public var avatarShape: Int32
public var activeIcon: String
public var lastRefreshTokens: Date
public var currentAccount: String? = nil
public var theme: Int32 = Int32(Theme.system.rawValue)
public var tintColor: Int32 = Int32(TintColor.accentColor2.rawValue)
public var avatarShape: Int32 = Int32(AvatarShape.circle.rawValue)
public var activeIcon: String = "Default"
public var lastRefreshTokens: Date = Date.distantPast
public var hapticTabSelectionEnabled: Bool
public var hapticRefreshEnabled: Bool
public var hapticButtonPressEnabled: Bool
public var hapticAnimationEnabled: Bool
public var hapticNotificationEnabled: Bool
public var hapticTabSelectionEnabled: Bool = true
public var hapticRefreshEnabled: Bool = true
public var hapticButtonPressEnabled: Bool = true
public var hapticAnimationEnabled: Bool = true
public var hapticNotificationEnabled: Bool = true
public var showSensitive: Bool
public var showPhotoDescription: Bool
public var menuPosition: Int32
public var showAvatarsOnTimeline: Bool
public var showFavouritesOnTimeline: Bool
public var showAltIconOnTimeline: Bool
public var warnAboutMissingAlt: Bool
public var showGridOnUserProfile: Bool
public var showReboostedStatuses: Bool
public var hideStatusesWithoutAlt: Bool
public var showSensitive: Bool = false
public var showApplicationBadge: Bool = false
public var showPhotoDescription: Bool = false
public var menuPosition: Int32 = Int32(MenuPosition.top.rawValue)
public var showAvatarsOnTimeline: Bool = false
public var showFavouritesOnTimeline: Bool = false
public var showAltIconOnTimeline: Bool = false
public var warnAboutMissingAlt: Bool = true
public var showGridOnUserProfile: Bool = false
public var showReboostedStatuses: Bool = false
public var hideStatusesWithoutAlt: Bool = false
public var customNavigationMenuItem1: Int32
public var customNavigationMenuItem2: Int32
public var customNavigationMenuItem3: Int32
public var customNavigationMenuItem1: Int32 = 1
public var customNavigationMenuItem2: Int32 = 2
public var customNavigationMenuItem3: Int32 = 5
init(
currentAccount: String? = nil,
@ -50,6 +51,7 @@ import EnvironmentKit
hapticAnimationEnabled: Bool = true,
hapticNotificationEnabled: Bool = true,
showSensitive: Bool = false,
showApplicationBadge: Bool = false,
showPhotoDescription: Bool = false,
menuPosition: Int32 = Int32(MenuPosition.top.rawValue),
showAvatarsOnTimeline: Bool = false,
@ -75,6 +77,7 @@ import EnvironmentKit
self.hapticAnimationEnabled = hapticAnimationEnabled
self.hapticNotificationEnabled = hapticNotificationEnabled
self.showSensitive = showSensitive
self.showApplicationBadge = showApplicationBadge
self.showPhotoDescription = showPhotoDescription
self.menuPosition = menuPosition
self.showAvatarsOnTimeline = showAvatarsOnTimeline

View File

@ -67,6 +67,7 @@ class ApplicationSettingsHandler {
applicationState.showGridOnUserProfile = defaultSettings.showGridOnUserProfile
applicationState.showReboostedStatuses = defaultSettings.showReboostedStatuses
applicationState.hideStatusesWithoutAlt = defaultSettings.hideStatusesWithoutAlt
applicationState.showApplicationBadge = defaultSettings.showApplicationBadge
if let menuPosition = MenuPosition(rawValue: Int(defaultSettings.menuPosition)) {
applicationState.menuPosition = menuPosition
@ -138,6 +139,12 @@ class ApplicationSettingsHandler {
defaultSettings.showSensitive = showSensitive
try? modelContext.save()
}
func set(showApplicationBadge: Bool, modelContext: ModelContext) {
let defaultSettings = self.get(modelContext: modelContext)
defaultSettings.showApplicationBadge = showApplicationBadge
try? modelContext.save()
}
func set(showPhotoDescription: Bool, modelContext: ModelContext) {
let defaultSettings = self.get(modelContext: modelContext)

View File

@ -38,8 +38,8 @@ import ClientKit
/// Last notification seen by the user.
public var lastSeenNotificationId: String?
/// Information about new notifications.
public var newNotificationsHasBeenAdded = false
/// Amount of new notifications.
public var amountOfNewNotifications = 0
/// Last status seen by the user.
public var lastSeenStatusId: String?
@ -119,12 +119,15 @@ import ClientKit
/// Hide statuses without ALT text.
public var hideStatusesWithoutAlt = false
/// Should show application badge.
public var showApplicationBadge = false
public func changeApplicationState(accountModel: AccountModel, instance: Instance?, lastSeenStatusId: String?, lastSeenNotificationId: String?) {
self.account = accountModel
self.lastSeenNotificationId = lastSeenNotificationId
self.lastSeenStatusId = lastSeenStatusId
self.amountOfNewStatuses = 0
self.newNotificationsHasBeenAdded = false
self.amountOfNewNotifications = 0
if let statusesConfiguration = instance?.configuration?.statuses {
self.statusMaxCharacters = statusesConfiguration.maxCharacters
@ -142,7 +145,7 @@ import ClientKit
self.lastSeenStatusId = nil
self.lastSeenNotificationId = nil
self.amountOfNewStatuses = 0
self.newNotificationsHasBeenAdded = false
self.amountOfNewNotifications = 0
self.statusMaxCharacters = ApplicationState.defaults.statusMaxCharacters
self.statusMaxMediaAttachments = ApplicationState.defaults.statusMaxMediaAttachments

View File

@ -22,5 +22,6 @@ public struct AppConstants {
public static let accountUri = "\(AppConstants.accountScheme)://\(accountCallbackPart)"
public static let imagePipelineCacheName = "dev.mczachurski.Vernissage.DataCache"
public static let backgroundFetcherName = "dev.mczachurski.Vernissage.NotificationFetcher"
public static let coreDataPersistantContainerName = "Vernissage"
}

View File

@ -4383,6 +4383,22 @@
}
}
},
"settings.error.notificationEnableFailed" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Error during enabling notifications."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Błąd podczas włączania powiadomień."
}
}
}
},
"settings.navigationBar.title" : {
"comment" : "Settings view.",
"localizations" : {
@ -5454,6 +5470,56 @@
}
}
},
"settings.title.notifications" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Notifications"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Powiadomienia"
}
}
}
},
"settings.title.notificationsDescription" : {
"comment" : "Application badge with amount of new notifications will be visible near the app icon.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Application badge with amount of new notifications will be visible new the app icon."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Licznik z ilością nowych powiadomień będzie wyświetlany obok ikony aplikacji."
}
}
}
},
"settings.title.notificationsTitle" : {
"comment" : "Show application badge",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show application badge"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Wyświetl ilość powiadomień"
}
}
}
},
"settings.title.other" : {
"localizations" : {
"en" : {

View File

@ -84,6 +84,7 @@
F87AEB922986C44E00434FB6 /* AuthorizationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87AEB912986C44E00434FB6 /* AuthorizationSession.swift */; };
F87AEB972986D16D00434FB6 /* AuthorisationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87AEB962986D16D00434FB6 /* AuthorisationError.swift */; };
F883402029B62AE900C3E096 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F883401F29B62AE900C3E096 /* SearchView.swift */; };
F886BBAC2AE7CF510083152B /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F886BBAB2AE7CF510083152B /* NotificationView.swift */; };
F88AB05329B3613900345EDE /* PhotoUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88AB05229B3613900345EDE /* PhotoUrl.swift */; };
F88AB05529B3626300345EDE /* ImageGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88AB05429B3626300345EDE /* ImageGrid.swift */; };
F88AB05829B36B8200345EDE /* AccountsPhotoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88AB05729B36B8200345EDE /* AccountsPhotoView.swift */; };
@ -281,6 +282,7 @@
F87AEB912986C44E00434FB6 /* AuthorizationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationSession.swift; sourceTree = "<group>"; };
F87AEB962986D16D00434FB6 /* AuthorisationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorisationError.swift; sourceTree = "<group>"; };
F883401F29B62AE900C3E096 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
F886BBAB2AE7CF510083152B /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = "<group>"; };
F88AB05229B3613900345EDE /* PhotoUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoUrl.swift; sourceTree = "<group>"; };
F88AB05429B3626300345EDE /* ImageGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrid.swift; sourceTree = "<group>"; };
F88AB05729B36B8200345EDE /* AccountsPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsPhotoView.swift; sourceTree = "<group>"; };
@ -634,6 +636,7 @@
F8B05ACA29B489B100857221 /* HapticsSectionView.swift */,
F8B05ACD29B48E2F00857221 /* MediaSettingsView.swift */,
F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */,
F886BBAB2AE7CF510083152B /* NotificationView.swift */,
);
path = Subviews;
sourceTree = "<group>";
@ -1162,6 +1165,7 @@
F89A46DC296EAACE0062125F /* SettingsView.swift in Sources */,
F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */,
F8675DD02A1FA40500A89959 /* WaterfallGrid.swift in Sources */,
F886BBAC2AE7CF510083152B /* NotificationView.swift in Sources */,
F85D4DFE29B78C8400345267 /* HashtagModel.swift in Sources */,
F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */,
);

View File

@ -15,5 +15,5 @@ class AppDelegate: NSObject, UIApplicationDelegate {
let sceneConfig: UISceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
sceneConfig.delegateClass = SceneDelegate.self
return sceneConfig
}
}
}

View File

@ -2,10 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>dev.mczachurski.Vernissage.NotificationFetcher</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -25,10 +25,19 @@
</array>
</dict>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UISceneConfigurations</key>
<dict/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
</dict>
</plist>

View File

@ -8,6 +8,7 @@ import SwiftUI
import PixelfedKit
import OAuthSwift
import EnvironmentKit
import BackgroundTasks
class SceneDelegate: NSObject, UISceneDelegate {
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {

View File

@ -13,6 +13,7 @@ import Nuke
import OSLog
import EnvironmentKit
import Semaphore
import UserNotifications
/// Service responsible for managing notifications.
@MainActor
@ -46,6 +47,58 @@ public class NotificationsService {
}
}
public func amountOfNewNotifications(for account: AccountModel, modelContext: ModelContext) async -> Int {
await semaphore.wait()
defer { semaphore.signal() }
guard let accessToken = account.accessToken else {
return 0
}
// Get maximimum downloaded stauts id.
guard let lastSeenNotificationId = self.getLastSeenNotificationId(accountId: account.id, modelContext: modelContext) else {
return 0
}
let client = PixelfedClient(baseURL: account.serverUrl).getAuthenticated(token: accessToken)
var notifications: [PixelfedKit.Notification] = []
var newestNotificationId = lastSeenNotificationId
// There can be more then 80 newest notifications, that's why we have to sometimes send more then one request.
while true {
do {
let downloadedNotifications = try await client.notifications(minId: newestNotificationId, limit: 80)
guard let firstNotification = downloadedNotifications.data.first else {
break
}
let visibleNotifications = downloadedNotifications.data.filter({ $0.id != lastSeenNotificationId })
notifications.append(contentsOf: visibleNotifications)
newestNotificationId = firstNotification.id
} catch {
ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadingNewStatuses")
break
}
}
// Return number of new notifications not visible yet on the timeline.
return notifications.count
}
/// Function sets application badge counts when notifications (and badge) are enabled.
public func setBadgeCount(_ count: Int) async throws {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
guard (settings.authorizationStatus == .authorized) || (settings.authorizationStatus == .provisional) else { return }
if settings.badgeSetting == .enabled {
try await center.setBadgeCount(count)
}
}
private func getLastSeenNotificationId(accountId: String, modelContext: ModelContext) -> String? {
let accountData = AccountDataHandler.shared.getAccountData(accountId: accountId, modelContext: modelContext)
return accountData?.lastSeenNotificationId

View File

@ -12,10 +12,12 @@ import EnvironmentKit
import WidgetKit
import SwiftData
import TipKit
import BackgroundTasks
@main
struct VernissageApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var phase
@State var applicationState = ApplicationState.shared
@State var client = Client.shared
@ -102,6 +104,17 @@ struct VernissageApp: App {
}
}
}
.onChange(of: phase) { oldValue, newValue in
switch newValue {
case .background: scheduleAppRefresh()
default: break
}
}
.backgroundTask(.appRefresh(AppConstants.backgroundFetcherName)) {
Task { @MainActor in
await self.setBadgeCount()
}
}
}
@MainActor
@ -127,7 +140,7 @@ struct VernissageApp: App {
self.applicationViewMode = .signIn
return
}
// Create model based on core data entity.
let accountModel = currentAccount.toAccountModel()
@ -228,7 +241,21 @@ struct VernissageApp: App {
let modelContext = self.modelContainer.mainContext
if let account = self.applicationState.account {
self.applicationState.newNotificationsHasBeenAdded = await NotificationsService.shared.newNotificationsHasBeenAdded(for: account, modelContext: modelContext)
self.applicationState.amountOfNewStatuses = await NotificationsService.shared.amountOfNewNotifications(for: account, modelContext: modelContext)
try? await NotificationsService.shared.setBadgeCount(self.applicationState.amountOfNewStatuses)
} else {
try? await NotificationsService.shared.setBadgeCount(0)
}
}
private func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: AppConstants.backgroundFetcherName)
request.earliestBeginDate = .now.addingTimeInterval(3600)
try? BGTaskScheduler.shared.submit(request)
}
private func setBadgeCount() async {
await self.calculateNewNotificationsInBackground()
scheduleAppRefresh()
}
}

View File

@ -90,9 +90,9 @@ struct MainView: View {
Image(systemName: "person.crop.circle")
case .notifications:
if applicationState.menuPosition == .top {
applicationState.newNotificationsHasBeenAdded ? Image(systemName: "bell.badge") : Image(systemName: "bell")
applicationState.amountOfNewNotifications > 0 ? Image(systemName: "bell.badge") : Image(systemName: "bell")
} else {
applicationState.newNotificationsHasBeenAdded
applicationState.amountOfNewNotifications > 0
? AnyView(
Image(systemName: "bell.badge")
.symbolRenderingMode(.palette)
@ -349,7 +349,7 @@ struct MainView: View {
private func calculateNewNotificationsInBackground() async {
if let account = self.applicationState.account {
self.applicationState.newNotificationsHasBeenAdded = await NotificationsService.shared.newNotificationsHasBeenAdded(for: account, modelContext: modelContext)
self.applicationState.amountOfNewNotifications = await NotificationsService.shared.amountOfNewNotifications(for: account, modelContext: modelContext)
}
}
}

View File

@ -98,7 +98,10 @@ struct NotificationsView: View {
}
try AccountDataHandler.shared.update(lastSeenNotificationId: linkable.data.first?.id, applicationState: self.applicationState, modelContext: modelContext)
self.applicationState.newNotificationsHasBeenAdded = false
// Refresh infomation about viewed notifications.
self.applicationState.amountOfNewNotifications = 0
try? await NotificationsService.shared.setBadgeCount(0)
}
} catch {
if !Task.isCancelled {
@ -135,7 +138,10 @@ struct NotificationsView: View {
}
try AccountDataHandler.shared.update(lastSeenNotificationId: linkable.data.first?.id, applicationState: self.applicationState, modelContext: modelContext)
self.applicationState.newNotificationsHasBeenAdded = false
// Refresh infomation about viewed notifications.
self.applicationState.amountOfNewNotifications = 0
try? await NotificationsService.shared.setBadgeCount(0)
self.minId = linkable.link?.minId
self.notifications.insert(contentsOf: linkable.data, at: 0)

View File

@ -39,6 +39,9 @@ struct SettingsView: View {
// Avatar shapes.
AvatarShapesSectionView()
// Notifications.
NotificationView()
// Media settings view.
MediaSettingsView()

View File

@ -0,0 +1,46 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//
import SwiftUI
import EnvironmentKit
import ServicesKit
struct NotificationView: View {
@Environment(ApplicationState.self) var applicationState
@Environment(\.colorScheme) var colorScheme
@Environment(\.modelContext) private var modelContext
var body: some View {
@Bindable var applicationState = applicationState
Section("settings.title.notifications") {
Toggle(isOn: $applicationState.showApplicationBadge) {
VStack(alignment: .leading) {
Text("settings.title.notificationsTitle", comment: "Show application badge")
Text("settings.title.notificationsDescription", comment: "Application badge with amount of new notifications will be visible near the app icon.")
.font(.footnote)
.foregroundColor(.customGrayColor)
}
}
.onChange(of: self.applicationState.showApplicationBadge) { oldValue, newValue in
Task { @MainActor in
do {
ApplicationSettingsHandler.shared.set(showApplicationBadge: newValue, modelContext: modelContext)
if newValue {
let center = UNUserNotificationCenter.current()
_ = try await center.requestAuthorization(options: [.alert, .sound, .badge])
} else {
try await NotificationsService.shared.setBadgeCount(0)
}
} catch {
ErrorService.shared.handle(error, message: "settings.error.notificationEnableFailed", showToastr: false)
}
}
}
}
}
}