diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c5e154..2197828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Other: * `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). +* Example app: the cart tab added `productVariants.first` regardless of whether that variant was purchasable. Shopify creates a line for an unavailable variant with `quantity: 0` and reports no `userErrors`, so on stores whose first variant is out of stock every add showed up as a `0x` line and the +/- buttons appeared dead. It now adds the first variant with `availableForSale == true`, marks out-of-stock products and lines, and reports it when Shopify clamps a requested quantity. (Pre-existing behaviour, not specific to 2026-07.) Migration guide: * Replace `image.originalSrc` with `image.url`. diff --git a/example/lib/screens/cart_tab.dart b/example/lib/screens/cart_tab.dart index 1d37f40..7f27633 100644 --- a/example/lib/screens/cart_tab.dart +++ b/example/lib/screens/cart_tab.dart @@ -9,6 +9,17 @@ import 'package:shopify_flutter/mixins/src/shopify_error.dart'; import 'package:shopify_flutter/models/src/cart/inputs/attribute_input/attribute_input.dart'; import 'package:shopify_flutter/shopify_flutter.dart'; +/// The first variant that can actually be bought, or null if none can. +/// +/// A cart line for a variant with `availableForSale == false` is created with +/// quantity 0, so adding one looks like nothing happened. +ProductVariant? purchasableVariant(Product product) { + for (final variant in product.productVariants) { + if (variant.availableForSale) return variant; + } + return null; +} + void logCartInfo(Cart cart) { log('log => cart id: ${cart.id}'); log('log => cart attributes: ${cart.attributes}'); @@ -102,9 +113,18 @@ class _CartTabState extends State { } void addLineItemToCart(Product product) async { + // Shopify accepts a line for a variant that isn't purchasable, but clamps + // its quantity to 0 and reports no userErrors — the line just sits in the + // cart as "0x". So pick a variant that is actually available for sale + // instead of blindly taking the first one. + final variant = purchasableVariant(product); + if (variant == null) { + context.showSnackBar('${product.title} is out of stock'); + return; + } final cartLineInput = CartLineUpdateInput( quantity: 1, - merchandiseId: product.productVariants.first.id, + merchandiseId: variant.id, attributes: [ const AttributeInput( key: 'color', @@ -112,25 +132,26 @@ class _CartTabState extends State { ), ], ); - // try { - final updatedCart = await shopifyCart.addLineItemsToCart( - cartId: cart!.id, - cartLineInputs: [cartLineInput], - ); - setState(() { - cart = updatedCart; - }); - logCartInfo(updatedCart); - if (!mounted) return; - context.showSnackBar('Added ${product.title} to cart'); - // } on ShopifyException catch (error) { - // log('addLineItemToCart ShopifyException: $error'); - // context.showSnackBar( - // error.errors?[0]["message"] ?? 'Error adding item to cart', - // ); - // } catch (error) { - // log('addLineItemToCart Error: $error'); - // } + try { + final updatedCart = await shopifyCart.addLineItemsToCart( + cartId: cart!.id, + cartLineInputs: [cartLineInput], + ); + setState(() { + cart = updatedCart; + }); + logCartInfo(updatedCart); + if (!mounted) return; + context.showSnackBar('Added ${product.title} to cart'); + } on ShopifyException catch (error) { + log('addLineItemToCart ShopifyException: $error'); + if (!mounted) return; + context.showSnackBar( + error.errors?[0]["message"] ?? 'Error adding item to cart', + ); + } catch (error) { + log('addLineItemToCart Error: $error'); + } } void onCartItemUpdate() async { @@ -186,14 +207,20 @@ class _CartTabState extends State { child: Column( children: [ ...products.map( - (product) => ListTile( - title: Text(product.title), - subtitle: Text(product.description ?? ''), - trailing: IconButton( - onPressed: () => addLineItemToCart(product), - icon: const Icon(Icons.add_shopping_cart), - ), - ), + (product) { + final inStock = purchasableVariant(product) != null; + return ListTile( + title: Text(product.title), + subtitle: Text( + inStock ? (product.description ?? '') : 'Out of stock', + ), + trailing: IconButton( + onPressed: + inStock ? () => addLineItemToCart(product) : null, + icon: const Icon(Icons.add_shopping_cart), + ), + ); + }, ), ], ), @@ -282,6 +309,17 @@ class _CartInfoState extends State { }); widget.onCartItemUpdate?.call(); if (!mounted) return; + // Shopify silently clamps the quantity to what it can actually fulfil + // (0 when the variant is out of stock), so say so rather than letting the + // button look broken. + final matches = updatedCart.lines.where((l) => l.id == line.id); + if (matches.isNotEmpty && matches.first.quantity != quantity) { + context.showSnackBar( + 'Shopify limited the quantity to ${matches.first.quantity} ' + '(requested $quantity) — not enough stock', + ); + return; + } context.showSnackBar('Updated item in cart'); } on ShopifyException catch (error) { log('onCartItemUpdate ShopifyException: ${error.errors?[0]["message"]}'); @@ -397,6 +435,13 @@ class _CartInfoState extends State { Text( 'Price: ${merchandise.price.amount} ${merchandise.price.currencyCode}', ), + if (!merchandise.availableForSale) + Text( + 'Out of stock — Shopify keeps this line at 0', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), Row( children: [ IconButton( diff --git a/test/shopify_flutter_test.dart b/test/shopify_flutter_test.dart index b467864..885af7a 100644 --- a/test/shopify_flutter_test.dart +++ b/test/shopify_flutter_test.dart @@ -56,6 +56,53 @@ void main() { expect(cart.cost?.totalAmount.amount, 10.0); }); + test('Cart line parses quantity from a cartLinesAdd payload', () { + // Guards the lines edges -> node -> quantity parse path. A line whose + // merchandise is unavailable comes back from Shopify with quantity 0 (and + // no userErrors), so 0 here must mean "the server said 0", never "we + // dropped the field". + final cart = Cart.fromJson({ + 'id': 'gid://shopify/Cart/1', + 'checkoutUrl': 'https://example.myshopify.com/cart/c/1', + 'createdAt': '2026-07-14T00:00:00Z', + 'totalQuantity': 3, + 'discountCodes': [], + 'cost': null, + 'lines': { + 'edges': [ + { + 'node': { + 'id': 'gid://shopify/CartLine/1?cart=abc', + 'quantity': 3, + 'merchandise': _merchandise( + id: 'gid://shopify/ProductVariant/1', + title: 'Small', + availableForSale: true, + quantityAvailable: 5, + ), + }, + }, + { + 'node': { + 'id': 'gid://shopify/CartLine/2?cart=abc', + 'quantity': 0, + 'merchandise': _merchandise( + id: 'gid://shopify/ProductVariant/2', + title: 'Large', + availableForSale: false, + quantityAvailable: 0, + ), + }, + }, + ], + }, + }); + + expect(cart.lines.map((l) => l.quantity), [3, 0]); + expect(cart.lines.first.variantId, 'gid://shopify/ProductVariant/1'); + expect(cart.lines.last.merchandise?.availableForSale, isFalse); + }); + test('ShopifyImage parses url (was originalSrc)', () { final image = ShopifyImage.fromJson({ 'id': 'gid://shopify/ImageSource/1', @@ -175,3 +222,25 @@ void main() { }); }); } + +/// A cart line's `merchandise` (a ProductVariant) as the Storefront API returns it. +Map _merchandise({ + required String id, + required String title, + required bool availableForSale, + required int quantityAvailable, +}) => + { + 'id': id, + 'title': title, + 'price': {'amount': '10.0', 'currencyCode': 'USD'}, + 'weight': 0.5, + 'weightUnit': 'KILOGRAMS', + 'requiresShipping': true, + 'sku': 'SKU-$title', + 'availableForSale': availableForSale, + 'quantityAvailable': quantityAvailable, + 'selectedOptions': [ + {'name': 'Size', 'value': title}, + ], + };