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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
101 changes: 73 additions & 28 deletions example/lib/screens/cart_tab.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}');
Expand Down Expand Up @@ -102,35 +113,45 @@ class _CartTabState extends State<CartTab> {
}

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',
value: 'red',
),
],
);
// 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 {
Expand Down Expand Up @@ -186,14 +207,20 @@ class _CartTabState extends State<CartTab> {
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),
),
);
},
),
],
),
Expand Down Expand Up @@ -282,6 +309,17 @@ class _CartInfoState extends State<CartInfo> {
});
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"]}');
Expand Down Expand Up @@ -397,6 +435,13 @@ class _CartInfoState extends State<CartInfo> {
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(
Expand Down
69 changes: 69 additions & 0 deletions test/shopify_flutter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -175,3 +222,25 @@ void main() {
});
});
}

/// A cart line's `merchandise` (a ProductVariant) as the Storefront API returns it.
Map<String, dynamic> _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},
],
};
Loading