Skip to content

Commit 5c35e4d

Browse files
authored
refactor: modernize iOS Plugin and Improve Purchase Flow (#560)
Refactors the iOS plugin, improves purchase flow, and updates the example app. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - iOS: Purchase History section with reload, loading, and error states. - Subscription screen shows detailed status (active/inactive, auto‑renew, expiration, expiring soon, environment) with human‑readable dates. - Improvements - Unified available‑purchases retrieval across platforms and more reliable subscription detection. - Better iOS diagnostics/logging and safer, consistent purchase/finish flows. - Robust input normalization for varied payload shapes. - Bug Fixes - Prevents UI updates after screens are closed; fixes loading vs empty-state handling. - Tests - Added unit test for iOS subscription parsing. - Chores - Added iOS example launch configuration; more resilient build file discovery. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent e92614e commit 5c35e4d

12 files changed

+1034
-428
lines changed

.vscode/launch.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@
8585
"cwd": "${workspaceFolder}/example",
8686
"flutterMode": "release",
8787
"args": []
88+
},
89+
{
90+
"name": "Example (iOS Device)",
91+
"type": "dart",
92+
"request": "launch",
93+
"program": "lib/main.dart",
94+
"cwd": "${workspaceFolder}/example",
95+
"args": []
8896
}
8997
],
9098
"compounds": []

android/build.gradle

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
import groovy.json.JsonSlurper
22

3-
def openiapVersionsFile = new File(rootDir.parentFile, 'openiap-versions.json')
3+
static File locateOpeniapVersionsFile(File startDir) {
4+
File current = startDir
5+
while (current != null) {
6+
File candidate = new File(current, 'openiap-versions.json')
7+
if (candidate.exists()) {
8+
return candidate
9+
}
10+
current = current.getParentFile()
11+
}
12+
throw new GradleException(
13+
"Unable to locate openiap-versions.json starting from ${startDir.absolutePath}"
14+
)
15+
}
16+
17+
def openiapVersionsFile = locateOpeniapVersionsFile(rootDir)
418
def openiapVersions = new JsonSlurper().parse(openiapVersionsFile)
519
def openiapGoogleVersion = openiapVersions['google']
620

example/ios/Podfile.lock

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ PODS:
22
- Flutter (1.0.0)
33
- flutter_inapp_purchase (0.0.1):
44
- Flutter
5-
- openiap (= 1.1.12)
6-
- openiap (1.1.12)
5+
- openiap (= 1.2.2)
6+
- openiap (1.2.2)
77

88
DEPENDENCIES:
99
- Flutter (from `Flutter`)
1010
- flutter_inapp_purchase (from `.symlinks/plugins/flutter_inapp_purchase/ios`)
11-
- openiap (from `https://github.com/hyodotdev/openiap-apple.git`, tag `1.1.12`)
11+
- openiap (from `https://github.com/hyodotdev/openiap-apple.git`, tag `1.2.2`)
1212

1313
EXTERNAL SOURCES:
1414
Flutter:
@@ -17,18 +17,18 @@ EXTERNAL SOURCES:
1717
:path: ".symlinks/plugins/flutter_inapp_purchase/ios"
1818
openiap:
1919
:git: https://github.com/hyodotdev/openiap-apple.git
20-
:tag: 1.1.12
20+
:tag: 1.2.2
2121

2222
CHECKOUT OPTIONS:
2323
openiap:
2424
:git: https://github.com/hyodotdev/openiap-apple.git
25-
:tag: 1.1.12
25+
:tag: 1.2.2
2626

2727
SPEC CHECKSUMS:
2828
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
29-
flutter_inapp_purchase: 8e8e96cc14bc141e3ffd432012fc9d4e347f8f71
30-
openiap: e207e50cc83dc28cd00c3701421a12eacdbd163a
29+
flutter_inapp_purchase: 50cbf415ba7058ee0042620d433cddd2ff4cb09d
30+
openiap: ea0f37fb1d08cd982ee4dfc219bd2e9c0656822c
3131

32-
PODFILE CHECKSUM: 9446ef2faab77e656d46b3aaf8938ab324919b93
32+
PODFILE CHECKSUM: 55b4ebbe613025bc8eab828c4569a30387a623c1
3333

3434
COCOAPODS: 1.16.2

example/lib/src/screens/available_purchases_screen.dart

Lines changed: 121 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ class _AvailablePurchasesScreenState extends State<AvailablePurchasesScreen> {
2020
List<Purchase> _availablePurchases = [];
2121
List<Purchase> _purchaseHistory = [];
2222
bool _loading = false;
23+
bool _historyLoading = false;
2324
bool _connected = false;
2425
String? _error;
26+
String? _historyError;
2527

2628
/// Convert various date formats to milliseconds timestamp
2729
int _parseTimestamp(dynamic date) {
@@ -100,14 +102,15 @@ class _AvailablePurchasesScreenState extends State<AvailablePurchasesScreen> {
100102
});
101103
await _loadPurchases();
102104
} catch (e) {
105+
if (!mounted) {
106+
debugPrint('Failed to initialize IAP connection: $e');
107+
return;
108+
}
103109
setState(() {
104110
_error = e.toString();
105-
});
106-
debugPrint('Failed to initialize IAP connection: $e');
107-
} finally {
108-
setState(() {
109111
_loading = false;
110112
});
113+
debugPrint('Failed to initialize IAP connection: $e');
111114
}
112115
}
113116

@@ -130,35 +133,90 @@ class _AvailablePurchasesScreenState extends State<AvailablePurchasesScreen> {
130133
// Remove duplicates by productId, keeping the most recent one
131134
final deduplicatedPurchases = _deduplicatePurchases(availablePurchases);
132135

133-
// Load purchase history
134-
List<Purchase> purchaseHistory = [];
135-
if (_platformOrDefault() == IapPlatform.IOS) {
136-
// iOS: include expired subscriptions as history
137-
purchaseHistory = await _iap.getAvailablePurchases(
138-
const PurchaseOptions(onlyIncludeActiveItemsIOS: false),
139-
);
140-
} else {
141-
// Android (GPB v8+): history of consumed items is not available via API
142-
// Show active purchases only
143-
purchaseHistory = [];
136+
if (!mounted) {
137+
return;
144138
}
145-
debugPrint('Loaded ${purchaseHistory.length} purchases from history');
146139

147140
setState(() {
148141
_availablePurchases = deduplicatedPurchases;
149-
_purchaseHistory = purchaseHistory;
142+
_loading = false;
150143
});
151144

152145
debugPrint(
153-
'After deduplication: ${_availablePurchases.length} unique active purchases');
146+
'After deduplication: ${deduplicatedPurchases.length} unique active purchases');
147+
148+
if (_platformOrDefault() == IapPlatform.IOS) {
149+
unawaited(_loadPurchaseHistory());
150+
} else if (mounted) {
151+
setState(() {
152+
_purchaseHistory = [];
153+
});
154+
}
154155
} catch (e) {
156+
if (!mounted) {
157+
debugPrint('Error loading purchases: $e');
158+
return;
159+
}
155160
setState(() {
156161
_error = e.toString();
162+
_loading = false;
163+
_historyLoading = false;
157164
});
158165
debugPrint('Error loading purchases: $e');
166+
}
167+
}
168+
169+
Future<void> _loadPurchaseHistory() async {
170+
setState(() {
171+
_historyLoading = true;
172+
_historyError = null;
173+
});
174+
175+
Timer? warningTimer;
176+
warningTimer = Timer(const Duration(seconds: 12), () {
177+
if (!mounted || !_historyLoading || _historyError != null) {
178+
return;
179+
}
180+
setState(() {
181+
_historyError =
182+
'Fetching purchase history is taking longer than expected. Still waiting...';
183+
});
184+
});
185+
186+
try {
187+
final purchaseHistory = await _iap.getAvailablePurchases(
188+
const PurchaseOptions(
189+
onlyIncludeActiveItemsIOS: false,
190+
alsoPublishToEventListenerIOS: false,
191+
),
192+
);
193+
debugPrint('Loaded ${purchaseHistory.length} purchases from history');
194+
195+
if (!mounted) {
196+
warningTimer?.cancel();
197+
return;
198+
}
199+
200+
setState(() {
201+
_purchaseHistory = purchaseHistory;
202+
_historyError = null;
203+
});
204+
} catch (e) {
205+
debugPrint('Error loading purchase history: $e');
206+
if (!mounted) {
207+
warningTimer?.cancel();
208+
return;
209+
}
210+
setState(() {
211+
_historyError = e.toString();
212+
});
159213
} finally {
214+
warningTimer?.cancel();
215+
if (!mounted) {
216+
return;
217+
}
160218
setState(() {
161-
_loading = false;
219+
_historyLoading = false;
162220
});
163221
}
164222
}
@@ -477,14 +535,31 @@ class _AvailablePurchasesScreenState extends State<AvailablePurchasesScreen> {
477535
const SizedBox(height: 24),
478536
],
479537

480-
// Purchase History Section
481-
if (_purchaseHistory.isNotEmpty) ...[
482-
const Text(
483-
'Purchase History',
484-
style: TextStyle(
485-
fontSize: 18,
486-
fontWeight: FontWeight.bold,
487-
),
538+
// Purchase History Section (iOS only)
539+
if (_platformOrDefault() == IapPlatform.IOS) ...[
540+
Row(
541+
crossAxisAlignment: CrossAxisAlignment.center,
542+
children: [
543+
const Expanded(
544+
child: Text(
545+
'Purchase History',
546+
style: TextStyle(
547+
fontSize: 18,
548+
fontWeight: FontWeight.bold,
549+
),
550+
),
551+
),
552+
OutlinedButton.icon(
553+
onPressed:
554+
_historyLoading ? null : _loadPurchaseHistory,
555+
icon: const Icon(Icons.history),
556+
label: Text(
557+
_purchaseHistory.isEmpty
558+
? 'Load History'
559+
: 'Reload History',
560+
),
561+
),
562+
],
488563
),
489564
const Text(
490565
'All purchases including consumed items',
@@ -494,13 +569,30 @@ class _AvailablePurchasesScreenState extends State<AvailablePurchasesScreen> {
494569
),
495570
),
496571
const SizedBox(height: 8),
497-
..._purchaseHistory
498-
.map((item) => _buildPurchaseHistoryItem(item)),
572+
if (_historyLoading)
573+
const Padding(
574+
padding: EdgeInsets.symmetric(vertical: 24),
575+
child: Center(child: CircularProgressIndicator()),
576+
)
577+
else if (_historyError != null)
578+
Padding(
579+
padding: const EdgeInsets.only(bottom: 12),
580+
child: Text(
581+
_historyError!,
582+
style:
583+
const TextStyle(color: Colors.red, fontSize: 12),
584+
),
585+
),
586+
if (_purchaseHistory.isNotEmpty)
587+
..._purchaseHistory
588+
.map((item) => _buildPurchaseHistoryItem(item)),
589+
const SizedBox(height: 24),
499590
],
500591

501592
// Empty State
502593
if (_availablePurchases.isEmpty &&
503594
_purchaseHistory.isEmpty &&
595+
!_historyLoading &&
504596
_error == null) ...[
505597
Center(
506598
child: Column(

example/lib/src/screens/purchase_flow_screen.dart

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -363,12 +363,7 @@ Product ID: ${error.productId ?? 'unknown'}
363363
),
364364
);
365365

366-
List<Product> products;
367-
if (result is FetchProductsResultProducts) {
368-
products = result.value ?? const <Product>[];
369-
} else {
370-
products = const <Product>[];
371-
}
366+
final products = result.inAppProducts();
372367

373368
debugPrint('📦 Received ${products.length} products from fetchProducts');
374369

0 commit comments

Comments
 (0)