lastminute.com logo

Technology

Sharing data between a React Native App and a native iOS Widget

fabrizio_duroni
fabrizio duroni
marco_de_lucchi
marco de lucchi

Learn how to display real-time information on an iOS Widget by leveraging data from a React Native app.


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.

My Trips Widget
My Trips Widget

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]

Architecture
Architecture

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.

App Group configuration on the App Store Connect
App Group configuration on the App Store Connect

App Group configuration on Xcode
App Group configuration on 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!


Read next

SwiftUI and the Text concatenations super powers

SwiftUI and the Text concatenations super powers

fabrizio_duroni
fabrizio duroni
marco_de_lucchi
marco de lucchi

Do you need a way to compose beautiful text with images and custom font like you are used with Attributed String. The Text component has everything we need to create some sort of 'attributed text' directly in SwiftUI. Let's go!!! [...]

A Monorepo Experiment: reuniting a JVM-based codebase

A Monorepo Experiment: reuniting a JVM-based codebase

luigi_noto
luigi noto

Continuing the Monorepo exploration series, we’ll see in action a real-life example of a monorepo for JVM-based languages, implemented with Maven, that runs in continuous integration. The experiment of reuniting a codebase of ~700K lines of code from many projects and shared libraries, into a single repository. [...]