Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
# 3.0.0
Removes every deprecated Storefront API field the package still queried, migrating to the 2026-07 replacements. Because several of these change the shape of the public Dart models, this is a breaking release. **This version now requires Storefront API `2026-07` or newer** (it relies on `Cart.lines.discountAllocations(lineLevelOnly:)` and cart-level `delivery`, both added in 2026-07); the default `storefrontApiVersion` is already `2026-07`.

Breaking model changes:
* `ShopifyImage.originalSrc` → **`ShopifyImage.url`** (`Image.originalSrc`/`src` were deprecated in favour of `url`). Affects every image across products, collections, articles and media.
* `Option.values` (`List<String>`) → **`Option.optionValues`** (`List<ProductOptionValue>`, each with `id` + `name`). New exported model `ProductOptionValue`. Mirrors `ProductOption.values` → `optionValues` in the API.
* `Order.subtotalPriceV2`/`totalPriceV2`/`totalShippingPriceV2`/`totalTaxV2`/`totalRefundedV2` → **`subtotalPrice`/`totalPrice`/`totalShippingPrice`/`totalTax`/`totalRefunded`** (the `*V2` money fields were deprecated).
* `CartCost` lost `totalTaxAmount`, `totalTaxAmountEstimated`, `totalDutyAmount`, `totalDutyAmountEstimated`. Shopify no longer returns tax/duty amounts on the cart ("no longer available and will be removed in a future version"), so these always returned null/false.
* Cart delivery addresses moved off the buyer identity:
* Removed `CartBuyerIdentity.deliveryAddressPreferences` and `CartBuyerIdentityInput.deliveryAddressPreferences` (the input field was deprecated).
* `Cart` gains **`delivery`** (`CartDelivery` → `List<CartSelectableAddress>`, each exposing `id`, `selected`, `oneTimeUse`, and a `CartDeliveryAddress`). New exported models: `CartDelivery`, `CartSelectableAddress`, `CartDeliveryAddress`.
* `CartInput` gains **`delivery`** (`CartDeliveryInput`). New exported inputs: `CartDeliveryInput`, `CartSelectableAddressInput`, `CartAddressInput`, `CartDeliveryAddressInput`. These replace the removed `DeliveryAddressInput` (`MailingAddressInput` remains for other uses). Note the new delivery inputs take `countryCode`/`provinceCode` (e.g. `AU`/`NSW`) rather than full names.
* New `ShopifyCart.addDeliveryAddresses(cartId:, addresses:)` (mutation `cartDeliveryAddressesAdd`) to add delivery addresses to an existing cart; `updateBuyerIdentityInCart` no longer accepts addresses.
* Removed the `Market` model and its export, and the `market` field on `Localization` and `Country` (`Localization.market`/`Country.market` were deprecated with no Storefront replacement).

Other:
* Line-level `discountAllocations` now requests `lineLevelOnly: false` so order-level allocations are included, and the deprecated cart-level `Cart.discountAllocations` selection was dropped (use the per-line allocations).
* Internal query fields migrated with no public-API impact: `Image` `originalSrc`→`url` in all documents, `ProductVariant.priceV2`/`compareAtPriceV2`→`price`/`compareAtPrice`, and `productByHandle`/`blogByHandle`/`pageByHandle`→`product`/`blog`/`page`.
* Updated the default `storefrontApiVersion` from **2024-07** to **2026-07**. 2024-07 has been sunset since July 2025; Shopify serves requests for an unsupported version from the oldest supported version instead, so callers relying on the default were silently drifting across versions as each one sunset. Callers that already pass `storefrontApiVersion` explicitly should ensure it is `2026-07` or newer (see the requirement above).
* Fixed three GraphQL documents that were rejected by the Storefront API on every currently supported version:
* `getXCollectionsAndNProductsSorted` declared `$collectonMetafields` (typo) but used `$collectionMetafields`, so the server rejected the document with `Variable "$collectionMetafields" is not defined`. The Dart caller was already passing the correctly spelled variable, so this method could never have succeeded.
* `getNArticlesSorted` selected the removed `Article.url` field. Replaced with `onlineStoreUrl`, which is what the `Article` model already parses.
* `getCollectionByIdQuery` passed a list to the single-collection lookup (`collection(ids: $ids)`); corrected to `collection(id: $id)`. This query has no caller in the package.
* Fixed `CartAddressInput` serialization. It is a Storefront "one of" input (exactly one field may be present), but `toJson` emitted both `copyFromCustomerAddressId` and `deliveryAddress`, so adding a cart delivery address (via `ShopifyCart.addDeliveryAddresses` or `CartInput.delivery`) failed with `'CartAddressInput' requires exactly one argument, but 2 were provided`. The unused (null) field is now omitted.
* Example app: builds on the Java 25 / AGP 9 toolchain (Gradle 9.6.1, AGP 9.3.0, built-in Kotlin); removed the hard-coded `price` filter on the collection tab that made collections appear empty; and fixed the Blog/Pages tabs spinning forever on a failed fetch (they now clear the spinner and show the error — e.g. a missing `unauthenticated_read_content` scope — or an empty state).

Migration guide:
* Replace `image.originalSrc` with `image.url`.
* Replace `option.values` (strings) with `option.optionValues.map((v) => v.name)`.
* Replace `order.totalPriceV2` etc. with `order.totalPrice` (drop the `V2` suffix).
* Replace `CartBuyerIdentityInput(deliveryAddressPreferences: [...])` with either `CartInput(delivery: CartDeliveryInput(addresses: [...]))` on create, or `ShopifyCart.addDeliveryAddresses(...)` on an existing cart, and read addresses back from `cart.delivery`.
* Remove any use of `localization.market` / `country.market`.

# 2.8.3
* `cartDiscountCodesUpdate`: optional warnings (`CartWarningCode` `errorCode` + localized `message`) surfaced on `CartDiscountCode`. When a discount code is not applicable, the payload-level `warnings` (`errorCode`/`errorMessage`) are attached to the corresponding `CartDiscountCode` via `copyWith` after parsing. These fields are absent from the `discountCodes` JSON, so `fromJson` leaves them null.

# 2.8.2
* Fix Shopify-side validation error `Nullability mismatch on variable $discountCodes and argument discountCodes ([String!] / [String!]!)` from `ShopifyCart.updateCartDiscountCodes` against newer Storefront API versions (e.g. `2026-01`) that promoted the `cartDiscountCodesUpdate` argument from `[String!]` to `[String!]!`. The SDK's mutation document still declared the variable as the older nullable type, so the server rejected the request. Promoted the variable to `[String!]!`. Per the GraphQL spec ("All Variable Usages Are Allowed"), a non-null variable is also valid for the older nullable argument shape, so this change is forward- and backward-compatible across Storefront API versions; the Dart caller signature (`required List<String> discountCodes`) already enforces non-null.

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ void main() {
// Optional | Needed only if needed to call admin api
adminAccessToken: "shpat_*******************",

// optional | default: 2024-07
storefrontApiVersion: '2024-07',
// optional | default: 2026-07
storefrontApiVersion: '2026-07',

// optional | default: null
cachePolicy: CachePolicy.cacheAndNetwork,
Expand Down Expand Up @@ -55,7 +55,7 @@ void main() {
> `adminAccessToken` is only required for admin api calls like `deleteCustomer()`.
If you are not using that function, you may not need to provide it.

> `storefrontApiVersion` default vesion is set to '2024-07'
> `storefrontApiVersion` default vesion is set to '2026-07'. This package requires **2026-07 or newer** (the cart operations rely on fields added in 2026-07). Shopify supports each version for 12 months after release and then falls forward to the oldest supported version, so prefer keeping this current.

> `language` defaults to 'en'. It is the default locale/language of the store. Only takes effect if the store supports provided language code.

Expand Down Expand Up @@ -370,7 +370,7 @@ For more information about filters visit:

1. https://shopify.dev/docs/custom-storefronts/building-with-the-storefront-api/products-collections/filter-products#step-1-query-products

2. https://shopify.dev/docs/api/storefront/2024-07/input-objects/productfilter
2. https://shopify.dev/docs/api/storefront/2026-07/input-objects/productfilter

<hr>

Expand Down
6 changes: 1 addition & 5 deletions example/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
plugins {
id("com.android.application")
id("kotlin-android")
id("com.android.built-in-kotlin")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
Expand All @@ -15,10 +15,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}

defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.example"
Expand Down
4 changes: 4 additions & 0 deletions example/android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
2 changes: 1 addition & 1 deletion example/android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
4 changes: 3 additions & 1 deletion example/android/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ pluginManagement {

plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("com.android.application") version "9.3.0" apply false
id("com.android.built-in-kotlin") version "9.3.0" apply false
// Kept on the classpath for legacy Flutter plugins that still apply KGP.
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}

Expand Down
55 changes: 51 additions & 4 deletions example/lib/screens/blog_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ class BlogTab extends StatefulWidget {
class BlogTabState extends State<BlogTab> {
List<Blog> blogs = [];
bool _isBlogsLoading = true;
String? _blogsError;
List<Page> pages = [];
bool _isPagesLoading = true;
String? _pagesError;

@override
void initState() {
Expand Down Expand Up @@ -44,7 +46,33 @@ class BlogTabState extends State<BlogTab> {
);
}

Widget _errorView(String title, String message) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 40),
const SizedBox(height: 12),
Text(title,
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.red)),
const SizedBox(height: 8),
Text(message, textAlign: TextAlign.center),
],
),
),
);
}

Widget _buildBlogList() {
if (!_isBlogsLoading && _blogsError != null) {
return _errorView('Failed to load blogs', _blogsError!);
}
if (!_isBlogsLoading && blogs.isEmpty) {
return const Center(child: Text('No blogs'));
}
return Center(
child: _isBlogsLoading
? const CircularProgressIndicator()
Expand Down Expand Up @@ -76,6 +104,12 @@ class BlogTabState extends State<BlogTab> {
}

Widget _buildPagesList() {
if (!_isPagesLoading && _pagesError != null) {
return _errorView('Failed to load pages', _pagesError!);
}
if (!_isPagesLoading && pages.isEmpty) {
return const Center(child: Text('No pages'));
}
return Center(
child: _isPagesLoading
? const CircularProgressIndicator()
Expand Down Expand Up @@ -110,7 +144,15 @@ class BlogTabState extends State<BlogTab> {
});
}
} catch (e) {
_isPagesLoading = false;
// Surface the error in the tab (e.g. ACCESS_DENIED when the Storefront
// token lacks the `unauthenticated_read_content` scope) instead of
// spinning forever. Must go through setState to rebuild.
if (mounted) {
setState(() {
_isPagesLoading = false;
_pagesError = e.toString();
});
}
debugPrint(e.toString());
}
}
Expand All @@ -126,7 +168,12 @@ class BlogTabState extends State<BlogTab> {
});
}
} catch (e) {
_isBlogsLoading = false;
if (mounted) {
setState(() {
_isBlogsLoading = false;
_blogsError = e.toString();
});
}
debugPrint(e.toString());
}
}
Expand Down Expand Up @@ -191,7 +238,7 @@ class ArtilePage extends StatelessWidget {
image: DecorationImage(
fit: BoxFit.contain,
image: NetworkImage(
article.image!.originalSrc,
article.image!.url,
))),
),
const SizedBox(height: 10),
Expand Down Expand Up @@ -240,7 +287,7 @@ class _PagePageState extends State<PagePage> {
});
}
} catch (e) {
_isLoading = false;
if (mounted) setState(() => _isLoading = false);
debugPrint(e.toString());
}
}
Expand Down
63 changes: 36 additions & 27 deletions example/lib/screens/cart_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -480,20 +480,29 @@ class _BuyerIndetityState extends State<BuyerIndetity> {
email: randomEmail(),
phone: randomPhone(),
countryCode: buyerIndetity?.countryCode,
deliveryAddressPreferences: [
const DeliveryAddressInput(
deliveryAddress: MailingAddressInput(
),
);
// Delivery addresses moved off the buyer identity in the 2026-07
// Storefront API. Add them to the cart directly instead.
await shopifyCart.addDeliveryAddresses(
cartId: cart.id,
addresses: [
const CartSelectableAddressInput(
selected: true,
address: CartAddressInput(
deliveryAddress: CartDeliveryAddressInput(
address1: '11 Hinkler Avenue',
city: 'Sydney',
country: 'Australia',
countryCode: 'AU',
provinceCode: 'NSW',
firstName: 'Anderson',
lastName: 'Fetter',
phone: '044444444',
zip: '2229',
),
),
],
),
),
],
);
setState(() {
cart = updatedCart;
Expand Down Expand Up @@ -550,30 +559,30 @@ class _BuyerIndetityState extends State<BuyerIndetity> {
),
ExpansionTile(
initiallyExpanded: true,
title: const Text('Delivery Address Preferences'),
title: const Text('Delivery Addresses'),
children: [
if (buyerIndetity?.deliveryAddressPreferences?.isEmpty ??
true)
if (cart.delivery?.addresses.isEmpty ?? true)
const ListTile(
title: Text('No delivery address preferences'),
),
...(buyerIndetity?.deliveryAddressPreferences ?? []).map(
(mailingAddress) => ListTile(
title: Text(
'${mailingAddress?.firstName} ${mailingAddress?.lastName}'),
subtitle: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${mailingAddress?.address1}',
),
Text(
'${mailingAddress?.city}, ${mailingAddress?.country}',
),
],
),
title: Text('No delivery addresses'),
),
...(cart.delivery?.addresses ?? []).map(
(selectable) {
final address = selectable.address;
return ListTile(
title: Text(
'${address.firstName} ${address.lastName}'),
subtitle: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${address.address1}'),
Text(
'${address.city}, ${address.countryCode}',
),
],
),
);
},
),
],
),
Expand Down
9 changes: 5 additions & 4 deletions example/lib/screens/collection_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class CollectionDetailScreenState extends State<CollectionDetailScreen> {
? ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.network(
product.images.first.originalSrc,
product.images.first.url,
width: 60,
height: 60,
fit: BoxFit.cover,
Expand All @@ -166,9 +166,10 @@ class CollectionDetailScreenState extends State<CollectionDetailScreen> {
4,
startCursor: null,
sortKey: SortKeyProductCollection.RELEVANCE,
filters: {
"price": {"min": 500, "max": 600},
},
// No filter: show every product in the collection. `filters` takes a
// Storefront `ProductFilter`, e.g. a price range —
// `filters: {"price": {"min": 0, "max": 100}}` — but a hard-coded range
// hides everything outside it, which looks like an empty collection.
);
if (mounted) {
setState(() {
Expand Down
2 changes: 1 addition & 1 deletion example/lib/screens/home_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class HomeTabState extends State<HomeTab> {
image: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(
product.images.first.originalSrc,
product.images.first.url,
)))
: const BoxDecoration(),
child: Stack(
Expand Down
2 changes: 1 addition & 1 deletion example/lib/screens/product_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class ProductDetailScreenState extends State<ProductDetailScreen> {
children: <Widget>[
product.images.isNotEmpty
? Image.network(
product.images.first.originalSrc,
product.images.first.url,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height / 3,
fit: BoxFit.cover,
Expand Down
2 changes: 1 addition & 1 deletion example/lib/screens/search_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ class SearchTabState extends State<SearchTab> {
),
leading: product.images.isNotEmpty
? Image.network(
product.images.first.originalSrc,
product.images.first.url,
width: 60,
height: 60,
fit: BoxFit.cover,
Expand Down
Loading
Loading