This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended. To summarise this work: - [x] created a new **Xcode** project; - [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project; - [x] created the `Locations` app and also, its `Libraries` package; - [x] created the `Shared` package to share dependencies between the apps; - [x] added a `Makefile` file and implemented some **environment** and **help** commands. Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Reviewed-on: rock-n-code/deep-linking-assignment#1
282 lines
14 KiB
Swift
282 lines
14 KiB
Swift
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 app’s 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: "")
|
||
}
|
||
}
|