Product and business details
Welcome to another exciting episode of Fabri & Marco exploring the world of Swift! Today, we’ll embark on a new journey with WidgetKit.
At lastminute.com, our mission is to lead the travel industry by leveraging technology to simplify, personalise and enhance our customers’ travel experience.
Today we are introducing the My Trips Widget, designed to effortlessly display flight details and hotel information on our customers’ iPhone home screens, refreshing automatically based on the current time and trip schedule.
In this article, we’ll take you on a tour of our codebase, covering the architecture structure, data sharing between the App and the Widget, and the challenges we faced during implementation.
Let’s dive in!
Architecture overview
If you’ve been following our recent posts about the “Inspiration & Deals” widget, you’ll recall our experiments with Apple’s SDK and APIs.
While providing data to widgets from public APIs was straightforward, our new widget presented a more complex challenge due to the sensitive nature of customer booking information.
To address this, we couldn’t simply share the authenticated network client code and business logic between the React Native app and the iOS native Widget.
Instead, we implemented App Groups and User Defaults to keep the data on the app and selectively share it with the Widget.
An App Group allows multiple apps developed by the same team to access one or more shared containers. […] You can also use an App Group to share data between an app extension or App Clip and its host app. [from developer.apple.com]
Configuring the App Group
Activating App Groups involves selecting them in the capabilities section of the bundle identifier information on the Apple Developer website and enabling them for our target using Xcode.
This architecture sets the stage for seamless data transfer. Now, let’s dive into how we implemented this from the React Native side.
Writing from React Native
Given the widget’s limited layout space, we decided not to display all the data from the Booking
data model available in the app.
Instead, we embraced a travel timeline approach, visualizing the temporal steps of a booking.
Taking as example a travel package composed by a flight and a hotel, the widget will show:
- The outbound flight, in the time frame between purchase date and landing time;
- The hotel, from when you land until the check-out date;
- The inbound flight, until you come back home.
An ad-hoc data model led by the WidgetBooking
interface was created to preformat the necessary data for the widget.
export interface WidgetBooking {
startDate: number;
endDate: number;
flight?: WidgetFlight;
hotel?: WidgetHotel;
}
export interface WidgetFlight {
stops: number;
code: string;
departureDate: string;
departureAirport: string;
arrivalDate: string;
arrivalAirport: string;
}
export interface WidgetHotel {
date: string;
nights: string;
name: string;
address: string;
image: string;
}
The transformation of data from the original model to the new structure is managed by the WidgetBookingsAdapter
.
This component converts a single booking into multiple WidgetBooking
instances, each representing a distinct step of the travel package.
export class WidgetBookingsAdapter {
constructor(
private readonly widgetBookingAdapter: WidgetBookingAdapter,
private readonly widgetBookingsSequenceCreator: WidgetBookingsSequenceCreator,
private readonly unixDateGenerator: UnixDateGenerator,
) {}
adapt(bookings: Booking[]): WidgetBooking[] | null {
if (bookings.length === 0) {
return null;
}
let today = this.unixDateGenerator.now();
const widgetBookings = bookings
.flatMap((booking) =>
this.widgetBookingAdapter.adapt(booking).filter((it) => it !== null),
)
.filter((it) => it.endDate > today)
.sort(
(booking, anotherBooking) =>
booking.startDate - anotherBooking.startDate,
);
this.widgetBookingsSequenceCreator.create(widgetBookings);
return widgetBookings;
}
}
Subsequently, the WidgetBookingsSequenceCreator
comes into play, sorting and generating of the travel timeline.
In charge of all data transfers to and from the widget is the WidgetBookingsRepository
class.
It handles the saving of adapted information and clearing them when necessary (e.g., logout).
To achieve this, it collaborates with the WidgetUserDefaultRepository
, relying on User Defaults stored in the previously established App Group.
Importantly, the WidgetBookingsRepository
also triggers a reload of the timeline by calling the WidgetKit API.
Among all operations that are performed when syncing and downloading the customers’ booking, we added one more line of code to call the WidgetBookingsRepository
so that the data is always synced with the Widget too.
export class WidgetBookingsRepository {
constructor(
private readonly widgetBookingsAdapter: WidgetBookingsAdapter,
private readonly widgetUserDefaultStore: WidgetUserDefaultRepository, //... other collaborators
) {}
async save(bookings: Booking[]) {
await this.widgetUserDefaultStore.saveWidgetBooking(
this.widgetBookingsAdapter.adapt(bookings),
);
await NativeModules.WidgetManager.reloadAllTimelines();
//...other code
}
async clear() {
await this.widgetUserDefaultStore.clear();
await NativeModules.WidgetManager.reloadAllTimelines();
//...other code
}
}
Reading from iOS
Talking about the native side (our favourite), the retrieval of data from React Native is handled by the LastminuteUserDefaults
class.
This class reads the User Defaults containing the travel timeline data and manages potential errors such as:
- User Defaults not initialised for the app group;
- Data not present in the shared user default entry (eg. user logged out that never logged in).
enum LastminuteUserDefaultsError: Error {
case StorageNotFound
case DataNotFound
}
class LastminuteUserDefaults {
private let userDefaults = UserDefaults(suiteName: AppGroup.appGroupName)
// ...other collaborato
func getBookingData() throws -> String? {
guard let validUserDefault = userDefaults else {
throw LastminuteUserDefaultsError.StorageNotFound
}
guard let bookingWidget = validUserDefault.value(forKey: AppGroup.bookingKey) as? String else {
throw LastminuteUserDefaultsError.DataNotFound
}
return bookingWidget
}
//...other code
}
The WidgetBookingsRepository
enters the scene, using the LastminuteUserDefaults
class, passing the content to the WidgetBookingsAdapter
.
This class adapts the JSON string into a Swift struct called WidgetBooking
and manages any error that could happen during the process.
class WidgetBookingsRepository {
private let widgetsBookingAdapter: WidgetBookingsAdapter
private let userDefaults: LastminuteUserDefaults
private let decoder: JSONDecoder
init(widgetsBookingAdapter: WidgetBookingsAdapter, userDefaults: LastminuteUserDefaults) {
self.widgetsBookingAdapter = widgetsBookingAdapter
self.userDefaults = userDefaults
self.decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
func get() async throws -> [WidgetBooking] {
let widgetBookingsJson = try readUserDefaults()
let widgetBookings = await WidgetBookingsAdapter(restClient: RestClient()).adapt(widgetBookingsJson: widgetBookingsJson)
return widgetBookings
}
private func readUserDefaults() throws -> [WidgetBookingJson] {
guard let widgetBookingJsonData = try userDefaults.getBookingData()?.data(using: .utf8) else {
throw WidgetBookingsRepositoryError.DataWrongEncoding
}
let widgetBookingsJson = try decoder.decode([WidgetBookingJson].self, from: widgetBookingJsonData)
guard !widgetBookingsJson.isEmpty else {
throw WidgetBookingsRepositoryError.NoBookingAvailable
}
return widgetBookingsJson
}
}
The journey continues with the creation of the Timeline Provider for our widget, the LastminuteBookingWidgetProvider
.
This class calls the BookingTimelineProviderFactory
to generate our custom extension of TimelineEntry
: the BookingEntry
.
Any errors in this process result in a default entry from the noBookingEntryFactory
, used to gracefully handle scenarios where booking data is unavailable.
struct BookingEntry: TimelineEntry {
var date: Date
let data: WidgetBooking?
}
let noBookingEntryFactory: (Date) -> BookingEntry = { date in BookingEntry(date: date, data: nil) }
class BookingTimelineProviderFactory {
func create(widgetBookings: [WidgetBooking]) -> Timeline<BookingEntry> {
var bookingEntries: [BookingEntry] = widgetBookings.map { widgetBooking in
return BookingEntry(date: widgetBooking.startDate, data: widgetBooking)
}
if !widgetBookings.isEmpty {
bookingEntries.append(noBookingEntryFactory(widgetBookings[widgetBookings.count - 1].endDate))
}
return Timeline(entries: bookingEntries, policy: .never)
}
}
//...other code
struct LastminuteBookingWidgetProvider: TimelineProvider {
private let widgetBookingsRepository: WidgetBookingsRepository
private let lastminuteUserDefaults: LastminuteUserDefaults
//...other code
func getTimeline(in context: Context, completion: @escaping (Timeline<BookingEntry>) -> ()) {
Task {
//...other code
do {
let widgetBookings = try await widgetBookingsRepository.get()
let bookingTimeline = BookingTimelineProviderFactory().create(widgetBookings: widgetBookings)
completion(bookingTimeline)
} catch {
completion(Timeline(entries: [noBookingEntryFactory(Date())], policy: .never))
}
}
}
}
Drawing with SwiftUI
Now we are ready to show our users bookings data using the power of SwiftUI.
The entry point for our widget is the LastminuteWidgetBundle
, of type WidgetBundle
, which includes all our widgets.
@main
struct LastminuteWidgetBundle: WidgetBundle {
var body: some Widget {
LastminuteBookingWidget()
//...other code
}
}
The LastminuteBookingWidget
class is a Widget
that uses the provider we implemented above to feed the BookingEntryView
, the UI entry point for our widget.
The BookingEntryView
uses the BookingEntry
generated by our provider to do a first routing step: if the data field of the BookingEntry
is nil
it means that the user has no booking (and this means that we will have only one entry), we will show the NoBookingView
view that act as a call to action to start a search inside the app . Instead, if the booking data is valid, we will show the BookingView
.
struct BookingEntryView : View {
var entry: BookingEntry
init(entry: BookingEntry) {
self.entry = entry
}
var body: some View {
if let validBooking = self.entry.data {
BookingView(widgetBooking: validBooking)
} else {
NoBookingView()
}
}
}
The NoBookingView is very simple: it shows three CTAs, one for each of our products (dynamic packages, hotel or flight).
In the background we have some beautiful Shape
(s) that act as a background and a message at the top.
struct NoBookingView: View {
var body: some View {
//...other views
SearchProductView(product: Product.flight)
SearchProductView(product: Product.dp)
SearchProductView(product: Product.hotel)
//...other views
}
}
If the BookingEntry
has some data, we show the BookingView
.
Here we route the user to the correct view based on the type of data contained in the timeline entry: FlightView
or HotelView
.
Each one of them is wrapped into a Link
, that will land the user to the booking section in the app if they want to check more details.
struct BookingView: View {
let widgetBooking: WidgetBooking
var body: some View {
if let flight = widgetBooking.flight {
Link(destination: BookingLinkGenerator().generateFor(product: Product.flight)) {
FlightBookingView(flight: flight)
}
}
if let hotel = widgetBooking.hotel {
Link(destination: BookingLinkGenerator().generateFor(product: Product.hotel)) {
HotelBookingView(hotel: hotel)
}
}
}
}
Last but not least the booking detail views.
In the FlightView
we show the departure and arrival information related to the Flight.
In the HotelView
we show the information about the user hotel, with the complete address and the review score.
struct FlightBookingView: View {
let flight: WidgetFlight
var body: some View {
// ...UI that uses flight information
}
}
struct HotelBookingView: View {
let hotel: WidgetHotel
var body: some View {
// ...UI that uses hotel information
}
}
Conclusion
Alright, let’s wrap this up! Our My Trips Widget journey was a rollercoaster of coding chaos, making React Native and iOS Widget friends wasn’t easy, but we are happy with the results.
We made sure to keep the data model centralized, avoid any double implementation of our booking data, network calls, and fetching logics.
We had lots of fun thinking on how to display the Flights and Hotels information at a glance and in the clearest way. Looking forward for feedback from our customers!
Some other cool articles are coming up focusing on the Widgets, stay tuned!