Skip to content

Commit f8056ec

Browse files
committed
Integrate Stripe checkout and cart persistence
Add Stripe-based checkout and per-user cart persistence plus push-deeplink handling. Highlights: - Stripe: add flutter_stripe dependency, initialize publishable key in main, and switch Android activity to FlutterFragmentActivity to support Stripe PaymentSheet. - Server: add a Supabase Edge Function (create-payment-intent) to create Stripe PaymentIntents and a DB migration to add payment fields to orders (payment_provider, payment_intent_id, payment_status). - Marketplace repo: new CreatePaymentIntentResult, createStripePaymentIntent() to call the edge function, and payment-aware placeOrder() plus stock validation and out-of-stock errors. - Cart: CartController persists cart per authenticated user to SharedPreferences (debounced), loads/clears on auth changes, and integrates Stripe PaymentSheet into the checkout flow; CartItemModel and ProductModel gains JSON serialization helpers. - Push notifications: add push_deeplink_routes helper, wire notification-opened handler in PushNotificationCoordinator, and implement registerOnNotificationOpenedHandler in PushNotificationService to route users into the app on notification taps. - Build/config: update pubspec.yaml/lock for flutter_stripe, tweak android/gradle.properties for JDK/tooling and Kotlin incremental compile, and adjust Supabase CLI config and functions config for the new edge function. These changes enable on-device checkout with server-side PaymentIntent creation, persist carts across sessions per user, and deep-link users from notifications into the appropriate screens.
1 parent c1d51eb commit f8056ec

32 files changed

Lines changed: 621 additions & 70 deletions
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.example.pet_dating_app
22

3-
import io.flutter.embedding.android.FlutterActivity
3+
import io.flutter.embedding.android.FlutterFragmentActivity
44

5-
class MainActivity : FlutterActivity()
5+
// [FlutterFragmentActivity] is required by flutter_stripe (PaymentSheet embeds Fragments).
6+
class MainActivity : FlutterFragmentActivity()

android/gradle.properties

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
22
android.useAndroidX=true
33
# Android Gradle Plugin / Gradle do not run on JDK 26; without a JDK 17–21 toolchain the build fails with "26.0.2" as the only message.
4-
# org.gradle.java.home=C:\\Program Files\\Android\\Android Studio\\jbr
4+
org.gradle.java.home=C:\\Program Files\\Android\\Android Studio\\jbr
55
# Enable parallel build to speed up the build process
66
org.gradle.parallel=true
77
# Enable the Gradle build cache to reuse outputs from previous builds
@@ -10,6 +10,8 @@ org.gradle.caching=true
1010
org.gradle.configureondemand=true
1111
# Enable the Gradle daemon to keep it running in the background for faster builds
1212
org.gradle.daemon=true
13+
# Kotlin incremental compile can fail when project (e.g. G:\\) and pub-cache (e.g. C:\\) are on different drive roots.
14+
kotlin.incremental=false
1315
# Enable Gradle's build scan plugin for better insights into build performance
1416
# org.gradle.scan=true
1517
# org.gradle.java.home=/usr/lib/jvm/java-21-openjdk

lib/controllers/cart_controller.dart

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import 'package:flutter_riverpod/flutter_riverpod.dart';
2+
import 'dart:async';
3+
import 'dart:convert';
4+
import 'package:shared_preferences/shared_preferences.dart';
5+
import 'package:flutter_stripe/flutter_stripe.dart';
26
import '../models/cart_item_model.dart';
37
import '../models/product_model.dart';
48
import '../repositories/marketplace_repository.dart';
@@ -42,8 +46,40 @@ class CartState {
4246
}
4347

4448
class CartController extends Notifier<CartState> {
49+
SharedPreferences? _prefs;
50+
Timer? _persistDebounce;
51+
String? _loadedForUserId;
52+
53+
static const _cartKeyPrefix = 'cart_items_v1_';
54+
4555
@override
4656
CartState build() {
57+
ref.listen<AuthState>(authProvider, (prev, next) {
58+
final prevUserId = prev?.user?.id;
59+
final nextUserId = next.user?.id;
60+
61+
if (next.status == AuthStatus.authenticated && nextUserId != null) {
62+
if (_loadedForUserId != nextUserId) {
63+
_loadedForUserId = nextUserId;
64+
Future.microtask(_loadPersistedCartForActiveUser);
65+
}
66+
return;
67+
}
68+
69+
// On logout / unauthenticated: clear memory + persisted cart for prior user.
70+
if (prevUserId != null) {
71+
Future.microtask(() => _clearPersistedCart(prevUserId));
72+
}
73+
_loadedForUserId = null;
74+
state = CartState(items: []);
75+
});
76+
77+
Future.microtask(_loadPersistedCartForActiveUser);
78+
ref.onDispose(() {
79+
_persistDebounce?.cancel();
80+
_persistDebounce = null;
81+
});
82+
4783
return CartState(items: []);
4884
}
4985

@@ -65,11 +101,13 @@ class CartController extends Notifier<CartState> {
65101
);
66102
state = state.copyWith(items: [...state.items, newItem]);
67103
}
104+
_schedulePersist();
68105
}
69106

70107
void removeCartItem(String itemId) {
71108
final newItems = state.items.where((i) => i.id != itemId).toList();
72109
state = state.copyWith(items: newItems);
110+
_schedulePersist();
73111
}
74112

75113
void updateQuantity(String itemId, int newQuantity) {
@@ -86,10 +124,12 @@ class CartController extends Notifier<CartState> {
86124
}).toList();
87125

88126
state = state.copyWith(items: newItems);
127+
_schedulePersist();
89128
}
90129

91130
void clearCart() {
92131
state = CartState();
132+
_schedulePersist();
93133
}
94134

95135
// -------------------------------------------------------------------------
@@ -102,13 +142,49 @@ class CartController extends Notifier<CartState> {
102142
state = state.copyWith(
103143
isCheckingOut: true, clearError: true, orderSuccess: false);
104144
try {
145+
// 1) Create Stripe PaymentIntent (server-side via Edge Function)
146+
final amountCents = (state.totalPrice * 100).round();
147+
final intent = await marketplaceRepository.createStripePaymentIntent(
148+
amountCents: amountCents,
149+
currency: 'usd',
150+
metadata: {
151+
'user_id': userId,
152+
'cart_items_count': state.items.length.toString(),
153+
},
154+
);
155+
156+
// 2) Confirm payment on-device
157+
await Stripe.instance.initPaymentSheet(
158+
paymentSheetParameters: SetupPaymentSheetParameters(
159+
paymentIntentClientSecret: intent.clientSecret,
160+
merchantDisplayName: 'PetFolio',
161+
),
162+
);
163+
await Stripe.instance.presentPaymentSheet();
164+
165+
// 3) Create order only after successful payment
105166
await marketplaceRepository.placeOrder(
106167
userId: userId,
107168
items: state.items,
169+
paymentProvider: 'stripe',
170+
paymentIntentId: intent.paymentIntentId,
108171
);
109172
// Clear cart after successful order
110173
state = CartState(orderSuccess: true);
174+
await _clearPersistedCart(userId);
111175
return true;
176+
} on StripeException catch (e) {
177+
state = state.copyWith(
178+
isCheckingOut: false,
179+
error: e.error.localizedMessage ?? 'Payment failed',
180+
);
181+
return false;
182+
} on MarketplaceOutOfStockException catch (e) {
183+
state = state.copyWith(
184+
isCheckingOut: false,
185+
error: e.toString(),
186+
);
187+
return false;
112188
} catch (e) {
113189
state = state.copyWith(
114190
isCheckingOut: false,
@@ -117,6 +193,61 @@ class CartController extends Notifier<CartState> {
117193
return false;
118194
}
119195
}
196+
197+
String _cartStorageKey(String userId) => '$_cartKeyPrefix$userId';
198+
199+
Future<void> _ensurePrefs() async {
200+
_prefs ??= await SharedPreferences.getInstance();
201+
}
202+
203+
Future<void> _loadPersistedCartForActiveUser() async {
204+
final auth = ref.read(authProvider);
205+
final userId = auth.user?.id;
206+
if (auth.status != AuthStatus.authenticated || userId == null) return;
207+
208+
await _ensurePrefs();
209+
final key = _cartStorageKey(userId);
210+
final raw = _prefs?.getString(key);
211+
if (raw == null || raw.isEmpty) return;
212+
213+
try {
214+
final decoded = jsonDecode(raw);
215+
if (decoded is! List) return;
216+
final items = decoded
217+
.whereType<Map>()
218+
.map((e) => CartItemModel.fromJson(
219+
e.map((k, v) => MapEntry(k.toString(), v)),
220+
))
221+
.toList();
222+
state = state.copyWith(items: items);
223+
} catch (_) {
224+
// Corrupt JSON => fail-safe to empty cart.
225+
await _prefs?.remove(key);
226+
state = state.copyWith(items: []);
227+
}
228+
}
229+
230+
void _schedulePersist() {
231+
_persistDebounce?.cancel();
232+
_persistDebounce = Timer(const Duration(milliseconds: 250), () async {
233+
final auth = ref.read(authProvider);
234+
final userId = auth.user?.id;
235+
if (auth.status != AuthStatus.authenticated || userId == null) return;
236+
await _ensurePrefs();
237+
final key = _cartStorageKey(userId);
238+
try {
239+
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
240+
await _prefs?.setString(key, json);
241+
} catch (_) {
242+
// Ignore persistence failure; cart remains in-memory.
243+
}
244+
});
245+
}
246+
247+
Future<void> _clearPersistedCart(String userId) async {
248+
await _ensurePrefs();
249+
await _prefs?.remove(_cartStorageKey(userId));
250+
}
120251
}
121252

122253
final cartProvider = NotifierProvider<CartController, CartState>(() {

lib/controllers/feed_controller.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ class FeedState {
3939
error: clearError ? null : (error ?? this.error),
4040
);
4141
}
42+
43+
/// Defensive expiry filter so long-lived sessions don’t show 24h stories past [StoryModel.expiresAt].
44+
/// Network fetch already uses `expires_at > now()`; this trims stale in-memory rows.
45+
List<StoryModel> get visibleStories =>
46+
stories.where((s) => !s.isExpired).toList();
4247
}
4348

4449
// ---------------------------------------------------------------------------

lib/controllers/health_controller.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
44
import '../models/pet_health_extended_models.dart';
55
import '../models/pet_health_models.dart';
66
import '../repositories/health_repository.dart';
7+
import 'pet_care_controller.dart';
78
import 'pet_controller.dart';
89

910
// ─────────────────────────────────────────────────────────────────────────────
@@ -307,8 +308,7 @@ class HealthNotifier extends Notifier<HealthState> {
307308
Future<void> upsertAppointment(PetVetAppointment appt) async {
308309
try {
309310
await _repo.upsertAppointment(appt);
310-
// Refresh care state (appointments are owned by petCareProvider)
311-
// This triggers a reload of appointments in PetCareNotifier.
311+
_syncCareAppointments(appt.petId);
312312
} catch (e) {
313313
state = state.copyWith(error: e.toString());
314314
}
@@ -317,11 +317,22 @@ class HealthNotifier extends Notifier<HealthState> {
317317
Future<void> cancelAppointment(String id) async {
318318
try {
319319
await _repo.cancelAppointment(id);
320+
final petId = ref.read(activePetProvider)?.id;
321+
if (petId != null) _syncCareAppointments(petId);
320322
} catch (e) {
321323
state = state.copyWith(error: e.toString());
322324
}
323325
}
324326

327+
void _syncCareAppointments(String appointmentPetId) {
328+
final activeId = ref.read(activePetProvider)?.id;
329+
if (activeId != appointmentPetId) return;
330+
Future.microtask(() {
331+
if (!ref.mounted) return;
332+
ref.read(petCareProvider.notifier).refresh();
333+
});
334+
}
335+
325336
// ── Vaccination mutations ────────────────────────────────────────────────
326337

327338
Future<void> upsertVaccination(PetVaccination vax) async {

lib/controllers/push_notification_coordinator.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import 'package:flutter/scheduler.dart';
44
import 'package:flutter_riverpod/flutter_riverpod.dart';
55

66
import '../services/push_notification_service.dart';
7+
import '../utils/push_deeplink_routes.dart';
8+
import '../utils/routes.dart';
79
import 'auth_controller.dart';
810

911
class PushNotificationCoordinator extends Notifier<void> {
@@ -16,6 +18,14 @@ class PushNotificationCoordinator extends Notifier<void> {
1618
final userChanged = prev?.user?.id != uid;
1719
if (becameAuthenticated || userChanged) {
1820
_schedule(() => PushNotificationService.registerTokenForUser(uid));
21+
_schedule(() async {
22+
await PushNotificationService.registerOnNotificationOpenedHandler(
23+
(message) {
24+
final route = routeForPushPayload(message.data);
25+
ref.read(routerProvider).go(route);
26+
},
27+
);
28+
});
1929
}
2030
} else if (next.status == AuthStatus.unauthenticated) {
2131
final prevUserId = prev?.user?.id;
@@ -28,6 +38,14 @@ class PushNotificationCoordinator extends Notifier<void> {
2838
final auth = ref.read(authProvider);
2939
if (auth.status == AuthStatus.authenticated && auth.user != null) {
3040
_schedule(() => PushNotificationService.registerTokenForUser(auth.user!.id));
41+
_schedule(() async {
42+
await PushNotificationService.registerOnNotificationOpenedHandler(
43+
(message) {
44+
final route = routeForPushPayload(message.data);
45+
ref.read(routerProvider).go(route);
46+
},
47+
);
48+
});
3149
}
3250
}
3351

lib/main.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import 'dart:developer' as developer;
2+
13
import 'package:flutter/foundation.dart';
24
import 'package:flutter/material.dart';
35
import 'package:flutter_riverpod/flutter_riverpod.dart';
46
import 'package:shared_preferences/shared_preferences.dart';
57
import 'package:supabase_flutter/supabase_flutter.dart';
68
import 'package:marionette_flutter/marionette_flutter.dart';
9+
import 'package:flutter_stripe/flutter_stripe.dart';
710
import 'controllers/auth_controller.dart';
811
import 'controllers/bootstrap_controller.dart';
912
import 'controllers/push_notification_coordinator.dart';
@@ -32,6 +35,11 @@ const bool _kFcmLogToken = bool.fromEnvironment(
3235
defaultValue: false,
3336
);
3437

38+
const String _kStripePublishableKey = String.fromEnvironment(
39+
'STRIPE_PUBLISHABLE_KEY',
40+
defaultValue: '',
41+
);
42+
3543
Future<void> main() async {
3644
if (!_kIntegrationTest) {
3745
if (kDebugMode && !_kFlutterDriverTest) {
@@ -48,6 +56,20 @@ Future<void> main() async {
4856
anonKey: supabaseInitAnonKey,
4957
);
5058

59+
if (_kStripePublishableKey.isNotEmpty) {
60+
try {
61+
Stripe.publishableKey = _kStripePublishableKey;
62+
await Stripe.instance.applySettings();
63+
} catch (e, st) {
64+
developer.log(
65+
'Stripe init failed (checkout disabled): $e',
66+
name: 'main',
67+
error: e,
68+
stackTrace: st,
69+
);
70+
}
71+
}
72+
5173
if (_kFcmLogToken) {
5274
await PushNotificationService.debugEmitFcmTokenForConsoleTest();
5375
}

lib/models/cart_item_model.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ class CartItemModel {
1313

1414
double get subtotal => product.price * quantity;
1515

16+
factory CartItemModel.fromJson(Map<String, dynamic> json) {
17+
return CartItemModel(
18+
id: json['id'] as String,
19+
product: ProductModel.fromJson(json['product'] as Map<String, dynamic>),
20+
quantity: (json['quantity'] as num?)?.toInt() ?? 1,
21+
);
22+
}
23+
24+
Map<String, dynamic> toJson() => {
25+
'id': id,
26+
'product': product.toJson(),
27+
'quantity': quantity,
28+
};
29+
1630
CartItemModel copyWith({
1731
String? id,
1832
ProductModel? product,

lib/models/post_model.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ class CommentModel {
44
final String id;
55
final String petId;
66
final String petName;
7+
/// From joined `pets.profile_image_url` when present (empty if unknown).
8+
final String petProfileImageUrl;
79
final String text;
810
final DateTime createdAt;
911

1012
CommentModel({
1113
required this.id,
1214
required this.petId,
1315
required this.petName,
16+
this.petProfileImageUrl = '',
1417
required this.text,
1518
required this.createdAt,
1619
});
@@ -22,6 +25,7 @@ class CommentModel {
2225
id: json['id'] as String,
2326
petId: json['pet_id'] as String,
2427
petName: petJson?['name'] as String? ?? 'Unknown',
28+
petProfileImageUrl: petJson?['profile_image_url'] as String? ?? '',
2529
text: json['text'] as String,
2630
createdAt: DateTime.parse(json['created_at'] as String),
2731
);

0 commit comments

Comments
 (0)