Payment And Order

Gxo Order Tracking

1. Overview

This document explains how GXO Order Tracking is implemented in the Toolstation mobile app.

Toolstation supports two types of order tracking:

  • Standard Tracking
    • FastTrack
    • Legacy carrier URLs
    • Redirect to external browser
  • GXO Tracking (In-App)
    • API-based
    • Real-time tracking events
    • Displayed directly inside the app UI

GXO is the preferred tracking method, but only for orders that meet specific eligibility criteria.

2. Standard vs GXO – Decision Flow

This is the actual logic used in OrderHistoryDetailPageBloc:

flowchart TD

%% ================================
%% USER → ORDER FLOW
%% ================================
A["User opens OrderHistoryPage"]
B["User taps an order"]
C["OrderHistoryDetailPage loads\n(order + tracking links)"]
D["User taps 'Track Order'"]

A --> B --> C --> D


%% ================================
%% BLOC DECISION FLOW
%% ================================
E["BLoC: Is GXO Eligible?"]
D --> E

E -->|Yes| F["Extract trackingId"]
F --> G["Navigate to OrderTrackingPage"]
G --> H["BLoC fetches GXO tracking data"]
H --> I["UI shows: loading / timeline / error"]


%% ================================
%% STANDARD TRACKING (Fallback)
%% ================================
E -->|No| S["Use Standard Tracking"]
S --> T{"Tracking Type?"}

T -->|FastTrack| U["FastTrackTrackingPage"]
T -->|"External URL"| V["Open WebView"]
T -->|"No link"| W["Show 'Tracking Unavailable'"]

3. GXO Eligibility Criteria

  • GXO Order Tracking eligibility is evaluated in two distinct stages:

Stage 1 — Determine Which Tracking Flow to Use

  • This stage decides whether the app should attempt to use GXO tracking at all.
  1. Feature Flag Check (Mandatory)
    • We only consider GXO tracking if the feature flag is true:
    bool get hasGXOTracking => isFeatureFlagEnabled(FeatureFlag.gxoTracking);
    
    • Mandatory: true
    • If false, the app will fall back to the legacy tracking flow immediately.
    • No further GXO checks are performed.

Stage 2 — Determine Whether to Enable the GXO Track Button

  • Once GXO tracking is selected as the tracking flow (Stage 1), the app performs additional eligibility checks to decide if the "Track Order" button should be enabled for this specific order.
  • GXO order-level eligibility is implemented in: lib/bloc/order/order_tracking_bloc.dart
  • An order is considered GXO-eligible only when ALL of the following are true:
  1. Order belongs to Default Branch (Delivery Order)
    • Mandatory: true
    • We are checking here for the delivery Order only because Order Tracking is only available on those order.
    order.branchId == Strings.defaultBranchId
    
  2. Order Status is Completed
    order.status == OrderStatus.completed
    
  3. Order Date ≥ GXO Cutoff Date
    (order.completedDate ?? order.orderDate).isAfter(orderRepository.gxoCutoffDate)
    

4. GXO Configuration

File: lib/model/configs/gxo_config.dart

Used by _GXOTrackingService to determine:

  • API base URL
  • Cutoff date for eligibility
const String _gxoBaseUrl = 'https://api-gxo.gke.pre-prod.nl.toolstation.dev';

@JsonSerializable()
class GXOConfig {
  const GXOConfig({this.baseUrl = _gxoBaseUrl, this.cutoffDate});

  final String baseUrl;
  @JsonKey(fromJson: _cutoffDateFromJson)
  final DateTime? cutoffDate;

  static DateTime? _cutoffDateFromJson(dynamic value) {
    try {
      return DateTime.parse(value);
    } catch (_) {
      return null;
    }
  }
}

Notes:

  • baseUrl comes from Remote Config (overridable).
  • cutoffDate defines whether the order supports GXO.

5. Complete User Navigation Flow

5.1 Order List → Order Details

  • On the Order History Page (order_history_page.dart) there is a sliverList which renders OrderHistoryItemV2 for each order.
OrderHistoryItemV2(
  order: order,
  onTap: () => Navigator.push(
    context,
    OrderHistoryDetailPage.routeOrder(order),
  ),
);

5.2 Order Details → Track Order Action

  • When user click on OrderHistoryItemV2 then the app navigate to Order History Detail Page via the route routeOrder and using that it creates the OrderHistoryDetailPageBloc which calls loadData method that fetch order details from the repository.
  • The loadData also calls _loadTrackingData and updates the response in the state, which contains tracking links for that specific order.

The page listens for bloc events:

_eventSub = bloc.eventStream.listen(_onBlocEvent);
  • After that, when user clicks on the Track Order button this method get called
  VoidCallback? handleTrackOrderButtonPressed(Order order, OrderTrackerData? orderTrackerData) {
    final fastTrackTrackingUrl = order.fastTrack?.trackingMapUrl;

    if (fastTrackTrackingUrl.isNotNullAndNotEmpty) {
      return () => trackOrder(fastTrackTrackingUrl!);
    } else if (hasGXOTracking && _isEligibleForGxo(order)) {
      return () => _addGxoNavigationEvent(orderTrackerData);
    } else if (orderTrackerData != null) {
      return _fetchAndTrackUrl;
    }
    return null;
  }

  void _addGxoNavigationEvent(OrderTrackerData? orderTrackerData) {
    if (orderTrackerData?.hasLinks != true) return;

    final url = orderTrackerData!.urls.first;
    final trackingId = _extractTrackingIdFromUrl(url);

    _event.add(TrackOrderNavigationEvent(trackingId));
  }

  String? _extractTrackingIdFromUrl(String url) {
    final uri = Uri.tryParse(url);
    if (uri != null && uri.queryParameters.containsKey('id')) {
      return uri.queryParameters['id'];
    }
    return null;
  }
  • The handleTrackOrderButtonPressed method checks if the order is eligible for GXO and if so, it emits the TrackOrderNavigationEvent event.

When GXO applies, the bloc emits:

emit(TrackOrderNavigationEvent(trackingId: trackingId));
  • The bloc.eventStream.listen(_onBlocEvent) listen to event and navigate user to OrderTrackingPage.
Navigator.push(
  context,
  OrderTrackingPage.route(trackingId: event.trackingId),
);
  • When the user navigates to OrderTrackingPage then it calls bloc.fetchTrackingData(widget.trackingId!) and sends the tracking ID to the bloc to fetch tracking data.
  Future<void> fetchTrackingData(String trackingId) async {
    trackingId = trackingId.trim();
    if (trackingId.isEmpty) return;

    _state.add(
      currentState.copyWith(
        isLoading: true,
        errorCode: null,
        isGenericError: false,
        trackingData: null,
      ),
    );

    try {
      final response = await _orderRepository.getGxoTrackingData(trackingId);

      _state.add(
        currentState.copyWith(
          isLoading: false,
          trackingData: response.trackingData,
          errorCode: null,
        ),
      );
    } on HttpServiceException catch (e, st) {
      LoggingService.log('fetch-gxo-tracking-data-failed', e, st);
      _state.add(
        currentState.copyWith(
          isLoading: false, 
          trackingData: null, 
          errorCode: e.statusCode,
        ),
      );
    } catch (e, st) {
      LoggingService.log('fetch-gxo-tracking-data-failed', e, st);
      _state.add(
        currentState.copyWith(
          isLoading: false,
          trackingData: null,
          errorCode: null,
          isGenericError: true,
        ),
      );
    }
  }
  • Then, on the OrderTrackingPage UI is wrapped under BlocConsumer to listen to the bloc state and update the UI accordingly.
BlocConsumer<OrderTrackingPageBloc, OrderTrackingPageBlocState>(
  builder: (context, bloc, state, child) {
    return FullPageSpinner(
      isLoading: state.isLoading,
      child: Column(
        children: [
          verticalMargin16,
          OrderTrackingSearchBar(
            duration: const Duration(milliseconds: 200),
            elevation: 2.0,
            controller: _searchController,
            focusNode: _focusNode,
            active: _focusNode.hasFocus,
            loading: state.isLoading,
            onClearPressed: _onClear,
            onSubmitted: bloc.fetchTrackingData,
          ),
          Expanded(
            child: Builder(
              builder: (context) {
                if (state.errorCode != null || state.isGenericError) {
                  return _OrderTrakingErrorBody(errorCode: state.errorCode);
                } else if (state.trackingData != null) {
                  return OrderTrackingDetails(trackingData: state.trackingData!);
                } else {
                  return const _OrderTrakingInitialBody();
                }
              },
            ),
          ),
        ],
      ),
    );
  },
),

6. Tracking ID Extraction Logic

Tracking links come from: GET /orders/{orderId}/tracking-links

Tracking Links Response:

{
  "data": [
    {
      "carrier_name": "PostNL Pakket",
      "tracking_links": [
        "https://tracking.pre-prod.nl.toolstation.dev?id=3SMJZU418494330"
      ]
    }
  ]
}

Example link:https://tracking.pre-prod.nl.toolstation.dev?id=3SMJZU418494330

Extraction of Tracking Id:

String? _extractTrackingIdFromUrl(String url) {
  final uri = Uri.tryParse(url);
  return uri?.queryParameters['id'];
}

7. OrderTrackingPage Lifecycle

order_tracking_page.dart

7.1 On Page Load

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    bloc.fetchTrackingData(widget.trackingId!);
  });
}

8. OrderTrackingPageBloc (GXO Tracking BLoC)

order_tracking_page_bloc.dart

Responsibilities:

  • Accept trackingId
  • Start loading
  • Call Repository
  • Emit success or error states

State:

class OrderTrackingPageBlocState {
  final bool isLoading;
  final OrderTrackingData? trackingData;
  final int? errorCode;       // HTTP error
  final bool isGenericError;  // Unknown error

  ...
}

Fetch Flow:

Future<void> fetchTrackingData(String trackingId) async {
  emit(state.copyWith(isLoading: true));
  try {
    final response = await _orderRepo.getGxoTrackingData(trackingId);
    emit(state.copyWith(
      isLoading: false,
      trackingData: response.trackingData,
    ));
  } on HttpServiceException catch (e) {
    emit(state.copyWith(
      isLoading: false,
      errorCode: e.statusCode,
    ));
  } catch (_) {
    emit(state.copyWith(
      isLoading: false,
      isGenericError: true,
    ));
  }
}

9. Repository Layer

order_repository.dart

Future<GXOOrderTrackingResponse> getGxoTrackingData(String trackingId) {
  return _trackingService.fetchGxoTrackingData(trackingId);
}

10. Tracking Service Layer

order_tracking_service.dart

Abstract:

abstract class TrackingService {
  DateTime get gxoCutoffDate;
  Future<GXOOrderTrackingResponse> fetchGxoTrackingData(String trackingId);
}

GXO Implementation:

final response = await _dio.get(
  '${gxoConfig.baseUrl}/ecom/v1/orders/tracking/$trackingId'
);

Errors:

if (response.statusCode != 200) {
  throw HttpServiceException(
    statusCode: response.statusCode!,
    message: response.data,
  );
}

11. GXO Response Model

model/order_tracking_data.dart

class GXOOrderTrackingResponse {
  final OrderTrackingData trackingData;

  GXOOrderTrackingResponse.fromJson(Map<String, dynamic> json)
      : trackingData = OrderTrackingData.fromJson(json['tracking']);
}

OrderTrackingData Example:

class OrderTrackingData {
  final String carrierName;
  final DateTime? actualArrival;
  final List<TrackingEvent> events;
}

TrackingEvent Example:

class TrackingEvent {
  final String carrierStatus;
  final String carrierDescription;
  final DateTime dateUTC;
}

12. Complete API Example

Tracking Links Response:

{
  "data": [
    {
      "carrier_name": "PostNL Pakket",
      "tracking_links": [
        "https://tracking.pre-prod.nl.toolstation.dev?id=3SMJZU418494330"
      ]
    }
  ]
}

GXO Tracking Response:

{
  "data": {
    "tracking": {
      "carrierName": "PostNL WS",
      "actualArrival": "2025-08-14 09:24:00",
      "events": [
        {
          "carrier_status": "O8",
          "carrier_description": "Delivered",
          "dateUTC": "2025-08-14T09:24:00"
        }
      ]
    }
  }
}

13. Important Files Index

LayerFile
UIorder_history_page.dart
order_history_detail_page.dart
order_tracking_page.dart
BLoCorder_history_detail_page_bloc.dart
order_tracking_page_bloc.dart
Repositoryorder_repository.dart
Serviceorder_tracking_service.dart
Modelsorder_tracking_data.dart
Configgxo_config.dart

GXO Order Tracking Feature Video: GXO Order Tracking Feature


Copyright © 2026