282 lines
14 KiB
Swift
Raw Normal View History

import WidgetKit
import SwiftUI
import WMF
// MARK: - Widget
struct OnThisDayWidget: Widget {
private let kind: String = WidgetController.SupportedWidget.onThisDay.identifier
public var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: OnThisDayProvider(), content: { entry in
OnThisDayView(entry: entry)
})
.configurationDisplayName(CommonStrings.onThisDayTitle)
.description(WMFLocalizedString("widget-onthisday-description", value: "Explore what happened on this day in history.", comment: "Description for 'On this day' view in iOS widget gallery"))
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
// MARK: - TimelineProvider
struct OnThisDayProvider: TimelineProvider {
// MARK: Nested Types
public typealias Entry = OnThisDayEntry
// MARK: Properties
private let dataStore = OnThisDayData.shared
// MARK: TimelineProvider
func placeholder(in: Context) -> OnThisDayEntry {
return dataStore.placeholderEntryFromLanguage(nil)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<OnThisDayEntry>) -> Void) {
dataStore.fetchLatestAvailableOnThisDayEntry { entry in
let currentDate = Date()
let nextUpdate: Date
if entry.error == nil && entry.isCurrent {
nextUpdate = currentDate.randomDateShortlyAfterMidnight() ?? currentDate
} else {
let components = DateComponents(hour: 2)
nextUpdate = Calendar.current.date(byAdding: components, to: currentDate) ?? currentDate
}
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
func getSnapshot(in context: Context, completion: @escaping (OnThisDayEntry) -> Void) {
dataStore.fetchLatestAvailableOnThisDayEntry(usingCache: context.isPreview) { entry in
completion(entry)
}
}
}
/// A data source and operation helper for all On This Day of the day widget data
final class OnThisDayData {
enum ErrorType {
case noInternet, featureNotSupportedInLanguage
var errorColor: Color {
switch self {
case .featureNotSupportedInLanguage:
return Color(.gray500)
case .noInternet:
return Color(.gray800)
}
}
var errorText: String {
/// These are intentionally in the iOS system language, not the app's primary language. Everything else in this widget is in the app's primary language.
switch self {
case .featureNotSupportedInLanguage:
return WMFLocalizedString("on-this-day-language-does-not-support-error", value: "Your primary Wikipedia language does not support On this day. You can update your primary Wikipedia in the apps Settings menu.", comment: "Error message shown when the user's primary language Wikipedia does not have the 'On this day' feature.")
case .noInternet:
return WMFLocalizedString("on-this-day-no-internet-error", value: "No data available", comment: "error message shown when device is not connected to internet")
}
}
}
// MARK: Properties
static let shared = OnThisDayData()
// From https://en.wikipedia.org/api/rest_v1/feed/onthisday/events/01/15, taken on 03 Sept 2020.
func placeholderEntryFromLanguage(_ language: MWKLanguageLink?) -> OnThisDayEntry {
let locale = NSLocale.wmf_locale(for: language?.languageCode)
let isRTL = MWKLanguageLinkController.isLanguageRTL(forContentLanguageCode: language?.contentLanguageCode)
let fullDate: String
let eventYear: String
let monthDay: String
let calendar = NSCalendar.wmf_utcGregorian()
let date = calendar?.date(from: DateComponents(year: 2001, month: 01, day: 15, hour: 0, minute: 0, second: 0))
if let date = date {
let fullDateFormatter = DateFormatter.wmf_longDateGMTFormatter(for: language?.languageCode)
fullDate = fullDateFormatter.string(from: date)
let yearWithEraFormatter = DateFormatter.wmf_yearGMTDateFormatter(for: language?.languageCode)
eventYear = yearWithEraFormatter.string(from: date)
let monthDayFormatter = DateFormatter.wmf_monthNameDayNumberGMTFormatter(for: language?.languageCode)
monthDay = monthDayFormatter.string(from: date)
} else {
fullDate = "January 15, 2001"
eventYear = "2001"
monthDay = "January 15"
}
let eventSnippet = WMFLocalizedString("widget-onthisday-placeholder-event-snippet", languageCode: language?.languageCode, value: "Wikipedia, a free wiki content encyclopedia, goes online.", comment: "Placeholder text for On This Day widget: Event describing launch of Wikipedia")
let articleSnippet = WMFLocalizedString("widget-onthisday-placeholder-article-snippet", languageCode: language?.languageCode, value: "Free online encyclopedia that anyone can edit", comment: "Placeholder text for On This Day widget: Article description for an article about Wikipedia")
// It seems that projects whose article is not titled "Wikipedia" (Arabic, for instance) all redirect this URL appropriately.
let articleURL = URL(string: ((language?.siteURL.absoluteString ?? "https://en.wikipedia.org") + "/wiki/Wikipedia"))
let entry: OnThisDayEntry = OnThisDayEntry(isRTLLanguage: isRTL,
error: nil,
onThisDayTitle: CommonStrings.onThisDayTitle(with: language?.languageCode),
monthDay: monthDay,
fullDate: fullDate,
otherEventsText: CommonStrings.onThisDayFooterWith(with: 49, languageCode: language?.languageCode),
contentURL: URL(string: "https://en.wikipedia.org/wiki/Wikipedia:On_this_day/Today"),
eventSnippet: eventSnippet,
eventYear: eventYear,
eventYearsAgo: String(format: WMFLocalizedDateFormatStrings.yearsAgo(forWikiLanguage: language?.languageCode), locale: locale, (Calendar.current.component(.year, from: Date()) - 2001)),
articleTitle: CommonStrings.plainWikipediaName(with: language?.languageCode),
articleSnippet: articleSnippet,
articleImage: UIImage(named: "W"),
articleURL: articleURL,
yearRange: CommonStrings.onThisDayHeaderDateRangeMessage(with: language?.languageCode, locale: locale, lastEvent: "69", firstEvent: "2019"))
return entry
}
// MARK: Public
func fetchLatestAvailableOnThisDayEntry(usingCache: Bool = false, _ userCompletion: @escaping (OnThisDayEntry) -> Void) {
let widgetController = WidgetController.shared
widgetController.startWidgetUpdateTask(userCompletion) { (dataStore, widgetTaskCompletion) in
guard let appLanguage = dataStore.languageLinkController.appLanguage,
WMFOnThisDayEventsFetcher.isOnThisDaySupported(by: appLanguage.languageCode) else {
let errorEntry = OnThisDayEntry.errorEntry(for: .featureNotSupportedInLanguage)
widgetTaskCompletion(errorEntry)
return
}
widgetController.fetchNewestWidgetContentGroup(with: .onThisDay, in: dataStore, isNetworkFetchAllowed: !usingCache) { (contentGroup) in
guard let contentGroup = contentGroup else {
widgetTaskCompletion(self.placeholderEntryFromLanguage(dataStore.languageLinkController.appLanguage))
return
}
self.assembleOnThisDayFromContentGroup(contentGroup, dataStore: dataStore, usingImageCache: usingCache, completion: widgetTaskCompletion)
}
}
}
private func assembleOnThisDayFromContentGroup(_ contentGroup: WMFContentGroup, dataStore: MWKDataStore, usingImageCache: Bool = false, completion: @escaping (OnThisDayEntry) -> Void) {
guard let previewEvents = contentGroup.contentPreview as? [WMFFeedOnThisDayEvent],
let previewEvent = previewEvents.first,
let entry = OnThisDayEntry(contentGroup)
else {
let language = dataStore.languageLinkController.appLanguage
completion(placeholderEntryFromLanguage(language))
return
}
let sendDataToWidget: (UIImage?) -> Void = { image in
var entryWithImage = entry
entryWithImage.articleImage = image
completion(entryWithImage)
}
let imageURLRaw = previewEvent.articlePreviews?.first?.thumbnailURL
guard let imageURL = imageURLRaw, !usingImageCache else {
/// The argument sent to `sendDataToWidget` on the next line could be nil because `imageURLRaw` is nil, or `cachedImage` returns nil.
/// `sendDataToWidget` will appropriately handle a nil image, so we don't need to worry about that here.
let cachedImage = dataStore.cacheController.imageCache.cachedImage(withURL: imageURLRaw)?.staticImage
sendDataToWidget(cachedImage)
return
}
dataStore.cacheController.imageCache.fetchImage(withURL: imageURL, failure: { _ in
sendDataToWidget(nil)
}, success: { fetchedImage in
sendDataToWidget(fetchedImage.image.staticImage)
})
}
private func handleNoInternetError(_ completion: @escaping (OnThisDayEntry) -> Void) {
let errorEntry = OnThisDayEntry.errorEntry(for: .noInternet)
completion(errorEntry)
}
}
// MARK: - Model
struct OnThisDayEntry: TimelineEntry {
let date = Date()
var isCurrent: Bool = false
let isRTLLanguage: Bool
let error: OnThisDayData.ErrorType?
let onThisDayTitle: String
let monthDay: String
let fullDate: String
let otherEventsText: String
let contentURL: URL?
let eventSnippet: String?
let eventYear: String
let eventYearsAgo: String?
let articleTitle: String?
let articleSnippet: String?
var articleImage: UIImage?
let articleURL: URL?
let yearRange: String
}
extension OnThisDayEntry {
init?(_ contentGroup: WMFContentGroup) {
guard
let midnightUTCDate = contentGroup.midnightUTCDate,
let calendar = NSCalendar.wmf_utcGregorian(),
let previewEvents = contentGroup.contentPreview as? [WMFFeedOnThisDayEvent],
let previewEvent = previewEvents.first,
let allEvents = contentGroup.fullContent?.object as? [WMFFeedOnThisDayEvent],
let earliestEventYear = allEvents.last?.yearString,
let latestEventYear = allEvents.first?.yearString,
let article = previewEvents.first?.articlePreviews?.first,
let year = previewEvent.year?.intValue,
let eventsCount = contentGroup.countOfFullContent?.intValue
else {
return nil
}
let language = contentGroup.siteURL?.wmf_languageCode
let monthDayFormatter = DateFormatter.wmf_monthNameDayNumberGMTFormatter(for: language)
monthDay = monthDayFormatter.string(from: midnightUTCDate)
var components = calendar.components([.month, .year, .day], from: midnightUTCDate)
components.year = year
if let date = calendar.date(from: components) {
let fullDateFormatter = DateFormatter.wmf_longDateGMTFormatter(for: language)
fullDate = fullDateFormatter.string(from: date)
let yearWithEraFormatter = DateFormatter.wmf_yearGMTDateFormatter(for: language)
eventYear = yearWithEraFormatter.string(from: date)
} else {
fullDate = ""
eventYear = ""
}
onThisDayTitle = CommonStrings.onThisDayTitle(with: language)
isRTLLanguage = contentGroup.isRTL
error = nil
otherEventsText = CommonStrings.onThisDayFooterWith(with: (eventsCount - 1), languageCode: language)
eventSnippet = previewEvent.text
articleTitle = article.displayTitle
articleSnippet = article.descriptionOrSnippet
articleURL = article.articleURL
let locale = NSLocale.wmf_locale(for: language)
let currentYear = Calendar.current.component(.year, from: Date())
let yearsSinceEvent = currentYear - year
eventYearsAgo = String(format: WMFLocalizedDateFormatStrings.yearsAgo(forWikiLanguage: language), locale: locale, yearsSinceEvent)
yearRange = CommonStrings.onThisDayHeaderDateRangeMessage(with: language, locale: locale, lastEvent: earliestEventYear, firstEvent: latestEventYear)
if let previewEventIndex = allEvents.firstIndex(of: previewEvent),
let dynamicURL = URL(string: "https://en.wikipedia.org/wiki/Wikipedia:On_this_day/Today?\(previewEventIndex)") {
contentURL = dynamicURL
} else {
contentURL = URL(string: "https://en.wikipedia.org/wiki/Wikipedia:On_this_day/Today")
}
isCurrent = contentGroup.isForToday
}
static func errorEntry(for error: OnThisDayData.ErrorType) -> OnThisDayEntry {
let isRTL = Locale.lineDirection(forLanguage: Locale.autoupdatingCurrent.languageCode ?? "en") == .rightToLeft
let destinationURL = URL(string: "wikipedia://explore")
return OnThisDayEntry(isRTLLanguage: isRTL, error: error, onThisDayTitle: "", monthDay: "", fullDate: "", otherEventsText: "", contentURL: destinationURL, eventSnippet: nil, eventYear: "", eventYearsAgo: nil, articleTitle: nil, articleSnippet: nil, articleImage: nil, articleURL: nil, yearRange: "")
}
}