Add Stripe payment integration
- Integrate flutter_stripe SDK for payment processing - Create StripeService with payment sheet flow - Add tip selection UI (0%, 15%, 18%, 20%, 25%) - Display fee breakdown (subtotal, tax, tip, service fee, card fee) - Update cart screen with "Pay $X.XX" button - Change MainActivity to FlutterFragmentActivity for Stripe compatibility - Update Android themes to AppCompat for payment sheet styling Fee structure: - 5% Payfrit service fee (customer pays) - 2.9% + $0.30 card processing fee (customer pays) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4ebfbbc03b
commit
5942deb0c5
9 changed files with 513 additions and 84 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
package com.payfrit.app
|
package com.payfrit.app
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterFragmentActivity()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting -->
|
<!-- Theme applied to the Android Window while the process is starting -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
<item name="android:windowBackground">@android:color/black</item>
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
|
import "package:flutter_stripe/flutter_stripe.dart";
|
||||||
import "package:provider/provider.dart";
|
import "package:provider/provider.dart";
|
||||||
|
|
||||||
import "app/app_router.dart" show AppRoutes;
|
import "app/app_router.dart" show AppRoutes;
|
||||||
|
|
@ -10,6 +11,11 @@ final GlobalKey<ScaffoldMessengerState> rootScaffoldMessengerKey =
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize Stripe with test publishable key
|
||||||
|
// This will be updated dynamically when processing payments if needed
|
||||||
|
Stripe.publishableKey = 'pk_test_sPBNzSyJ9HcEPJGC7dSo8NqN';
|
||||||
|
|
||||||
runApp(const PayfritApp());
|
runApp(const PayfritApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../models/cart.dart';
|
||||||
import '../models/menu_item.dart';
|
import '../models/menu_item.dart';
|
||||||
import '../services/api.dart';
|
import '../services/api.dart';
|
||||||
import '../services/order_polling_service.dart';
|
import '../services/order_polling_service.dart';
|
||||||
|
import '../services/stripe_service.dart';
|
||||||
|
|
||||||
/// Helper class to store modifier breadcrumb paths
|
/// Helper class to store modifier breadcrumb paths
|
||||||
class ModifierPath {
|
class ModifierPath {
|
||||||
|
|
@ -29,9 +30,37 @@ class CartViewScreen extends StatefulWidget {
|
||||||
class _CartViewScreenState extends State<CartViewScreen> {
|
class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
Cart? _cart;
|
Cart? _cart;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
bool _isProcessingPayment = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
Map<int, MenuItem> _menuItemsById = {};
|
Map<int, MenuItem> _menuItemsById = {};
|
||||||
|
|
||||||
|
// Tip options as percentages
|
||||||
|
static const List<int> _tipPercentages = [0, 15, 18, 20, 25];
|
||||||
|
int _selectedTipIndex = 1; // Default to 15%
|
||||||
|
|
||||||
|
double get _tipAmount {
|
||||||
|
if (_cart == null) return 0.0;
|
||||||
|
return _cart!.subtotal * (_tipPercentages[_selectedTipIndex] / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
FeeBreakdown get _feeBreakdown {
|
||||||
|
if (_cart == null) {
|
||||||
|
return const FeeBreakdown(
|
||||||
|
subtotal: 0,
|
||||||
|
tax: 0,
|
||||||
|
tip: 0,
|
||||||
|
payfritFee: 0,
|
||||||
|
cardFee: 0,
|
||||||
|
total: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return StripeService.calculateFees(
|
||||||
|
subtotal: _cart!.subtotal,
|
||||||
|
tax: _cart!.tax,
|
||||||
|
tip: _tipAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -146,14 +175,44 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submitOrder() async {
|
Future<void> _processPaymentAndSubmit() async {
|
||||||
try {
|
if (_cart == null) return;
|
||||||
|
|
||||||
final appState = context.read<AppState>();
|
final appState = context.read<AppState>();
|
||||||
final cartOrderId = appState.cartOrderId;
|
final cartOrderId = appState.cartOrderId;
|
||||||
if (cartOrderId == null) return;
|
final businessId = appState.selectedBusinessId;
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
if (cartOrderId == null || businessId == null) return;
|
||||||
|
|
||||||
|
setState(() => _isProcessingPayment = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Process payment with Stripe
|
||||||
|
final paymentResult = await StripeService.processPayment(
|
||||||
|
context: context,
|
||||||
|
businessId: businessId,
|
||||||
|
orderId: cartOrderId,
|
||||||
|
subtotal: _cart!.subtotal,
|
||||||
|
tax: _cart!.tax,
|
||||||
|
tip: _tipAmount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!paymentResult.success) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isProcessingPayment = false);
|
||||||
|
|
||||||
|
if (paymentResult.error != 'Payment cancelled') {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(paymentResult.error ?? 'Payment failed'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Payment successful, now submit the order
|
||||||
await Api.submitOrder(orderId: cartOrderId);
|
await Api.submitOrder(orderId: cartOrderId);
|
||||||
|
|
||||||
// Set active order for polling (status 1 = submitted)
|
// Set active order for polling (status 1 = submitted)
|
||||||
|
|
@ -187,20 +246,27 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text("Order submitted successfully! You'll receive notifications as your order is prepared."),
|
content: Text(
|
||||||
|
"Payment successful! Order placed. You'll receive notifications as your order is prepared.",
|
||||||
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Navigate back
|
// Navigate back
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
if (!mounted) return;
|
||||||
_error = e.toString();
|
setState(() => _isProcessingPayment = false);
|
||||||
_isLoading = false;
|
|
||||||
});
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Error: ${e.toString()}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -492,6 +558,8 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
Widget _buildCartSummary() {
|
Widget _buildCartSummary() {
|
||||||
if (_cart == null) return const SizedBox.shrink();
|
if (_cart == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final fees = _feeBreakdown;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[100],
|
color: Colors.grey[100],
|
||||||
|
|
@ -506,53 +574,79 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
// Tip Selection
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
const Text(
|
||||||
"Subtotal",
|
"Add a tip",
|
||||||
style: TextStyle(fontSize: 16),
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
Text(
|
|
||||||
"\$${_cart!.subtotal.toStringAsFixed(2)}",
|
|
||||||
style: const TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Sales tax
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
children: List.generate(_tipPercentages.length, (index) {
|
||||||
children: [
|
final isSelected = _selectedTipIndex == index;
|
||||||
const Text(
|
final percent = _tipPercentages[index];
|
||||||
"Tax (8.25%)",
|
return Expanded(
|
||||||
style: TextStyle(fontSize: 16),
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: index == 0 ? 0 : 4,
|
||||||
|
right: index == _tipPercentages.length - 1 ? 0 : 4,
|
||||||
),
|
),
|
||||||
Text(
|
child: GestureDetector(
|
||||||
"\$${_cart!.tax.toStringAsFixed(2)}",
|
onTap: () => setState(() => _selectedTipIndex = index),
|
||||||
style: const TextStyle(fontSize: 16),
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? Colors.black : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? Colors.black : Colors.grey.shade300,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
percent == 0 ? "No tip" : "$percent%",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isSelected ? Colors.white : Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Subtotal
|
||||||
|
_buildSummaryRow("Subtotal", fees.subtotal),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Tax
|
||||||
|
_buildSummaryRow("Tax (8.25%)", fees.tax),
|
||||||
// Only show delivery fee for delivery orders (OrderTypeID = 3)
|
// Only show delivery fee for delivery orders (OrderTypeID = 3)
|
||||||
if (_cart!.deliveryFee > 0 && _cart!.orderTypeId == 3) ...[
|
if (_cart!.deliveryFee > 0 && _cart!.orderTypeId == 3) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Row(
|
_buildSummaryRow("Delivery Fee", _cart!.deliveryFee),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
"Delivery Fee",
|
|
||||||
style: TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
"\$${_cart!.deliveryFee.toStringAsFixed(2)}",
|
|
||||||
style: const TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
// Tip
|
||||||
|
if (_tipAmount > 0) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildSummaryRow("Tip", _tipAmount),
|
||||||
],
|
],
|
||||||
const Divider(height: 24),
|
const SizedBox(height: 6),
|
||||||
|
// Payfrit fee
|
||||||
|
_buildSummaryRow("Service Fee", fees.payfritFee, isGrey: true),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Card processing fee
|
||||||
|
_buildSummaryRow("Card Processing", fees.cardFee, isGrey: true),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Total
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -564,7 +658,7 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"\$${_cart!.total.toStringAsFixed(2)}",
|
"\$${fees.total.toStringAsFixed(2)}",
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -576,15 +670,27 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _cart!.itemCount > 0 ? _submitOrder : null,
|
onPressed: (_cart!.itemCount > 0 && !_isProcessingPayment)
|
||||||
|
? _processPaymentAndSubmit
|
||||||
|
: null,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
disabledBackgroundColor: Colors.grey,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: _isProcessingPayment
|
||||||
"Submit Order",
|
? const SizedBox(
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
"Pay \$${fees.total.toStringAsFixed(2)}",
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -594,6 +700,28 @@ class _CartViewScreenState extends State<CartViewScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryRow(String label, double amount, {bool isGrey = false}) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: isGrey ? Colors.grey.shade600 : Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"\$${amount.toStringAsFixed(2)}",
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: isGrey ? Colors.grey.shade600 : Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _confirmRemoveItem(OrderLineItem item, String itemName) {
|
void _confirmRemoveItem(OrderLineItem item, String itemName) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
List<MenuItem> _allItems = [];
|
List<MenuItem> _allItems = [];
|
||||||
final Map<int, List<MenuItem>> _itemsByCategory = {};
|
final Map<int, List<MenuItem>> _itemsByCategory = {};
|
||||||
final Map<int, List<MenuItem>> _itemsByParent = {};
|
final Map<int, List<MenuItem>> _itemsByParent = {};
|
||||||
|
final Map<int, int> _categorySortOrder = {}; // categoryId -> sortOrder
|
||||||
|
final Map<int, String> _categoryNames = {}; // categoryId -> categoryName
|
||||||
|
|
||||||
// Track which category is currently expanded (null = none)
|
// Track which category is currently expanded (null = none)
|
||||||
int? _expandedCategoryId;
|
int? _expandedCategoryId;
|
||||||
|
|
@ -79,6 +81,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
void _organizeItems() {
|
void _organizeItems() {
|
||||||
_itemsByCategory.clear();
|
_itemsByCategory.clear();
|
||||||
_itemsByParent.clear();
|
_itemsByParent.clear();
|
||||||
|
_categorySortOrder.clear();
|
||||||
|
_categoryNames.clear();
|
||||||
|
|
||||||
print('[MenuBrowse] _organizeItems: ${_allItems.length} total items');
|
print('[MenuBrowse] _organizeItems: ${_allItems.length} total items');
|
||||||
|
|
||||||
|
|
@ -90,7 +94,10 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
categoryItemIds.add(item.itemId);
|
categoryItemIds.add(item.itemId);
|
||||||
// Just register the category key (empty list for now)
|
// Just register the category key (empty list for now)
|
||||||
_itemsByCategory.putIfAbsent(item.itemId, () => []);
|
_itemsByCategory.putIfAbsent(item.itemId, () => []);
|
||||||
print('[MenuBrowse] Category found: ${item.name} (ID=${item.itemId})');
|
// Store the sort order and name for this category
|
||||||
|
_categorySortOrder[item.itemId] = item.sortOrder;
|
||||||
|
_categoryNames[item.itemId] = item.name;
|
||||||
|
print('[MenuBrowse] Category found: ${item.name} (ID=${item.itemId}, sortOrder=${item.sortOrder})');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,7 +143,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
|
|
||||||
List<int> _getUniqueCategoryIds() {
|
List<int> _getUniqueCategoryIds() {
|
||||||
final categoryIds = _itemsByCategory.keys.toList();
|
final categoryIds = _itemsByCategory.keys.toList();
|
||||||
categoryIds.sort();
|
// Sort by sortOrder (from _categorySortOrder), not by ItemID
|
||||||
|
categoryIds.sort((a, b) {
|
||||||
|
final orderA = _categorySortOrder[a] ?? 0;
|
||||||
|
final orderB = _categorySortOrder[b] ?? 0;
|
||||||
|
return orderA.compareTo(orderB);
|
||||||
|
});
|
||||||
return categoryIds;
|
return categoryIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,11 +279,15 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
final categoryIndex = index - 1;
|
final categoryIndex = index - 1;
|
||||||
final categoryId = categoryIds[categoryIndex];
|
final categoryId = categoryIds[categoryIndex];
|
||||||
final items = _itemsByCategory[categoryId] ?? [];
|
final items = _itemsByCategory[categoryId] ?? [];
|
||||||
final categoryName = items.isNotEmpty
|
// Use stored category name from the category item itself
|
||||||
? items.first.categoryName
|
final categoryName = _categoryNames[categoryId] ?? "Category $categoryId";
|
||||||
: "Category $categoryId";
|
|
||||||
final isExpanded = _expandedCategoryId == categoryId;
|
final isExpanded = _expandedCategoryId == categoryId;
|
||||||
|
|
||||||
|
// Debug: Print which items are being shown for which category
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
print('[MenuBrowse] DISPLAY: Category "$categoryName" (ID=$categoryId) showing items: ${items.map((i) => i.name).join(", ")}');
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -427,15 +443,16 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
semanticLabel: categoryName,
|
semanticLabel: categoryName,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
// No image - show large styled category name
|
// No image - show white background with dark forest green text
|
||||||
|
const darkForestGreen = Color(0xFF1B4D3E);
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
Theme.of(context).colorScheme.primary,
|
Colors.white,
|
||||||
Theme.of(context).colorScheme.tertiary,
|
Colors.grey.shade100,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -447,13 +464,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
color: darkForestGreen,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
offset: Offset(2, 2),
|
offset: Offset(1, 1),
|
||||||
blurRadius: 4,
|
blurRadius: 2,
|
||||||
color: Colors.black54,
|
color: Colors.black26,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -489,7 +506,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
children: [
|
children: [
|
||||||
// Category image background or styled text fallback
|
// Category image background or styled text fallback
|
||||||
_buildCategoryBackground(categoryId, categoryName),
|
_buildCategoryBackground(categoryId, categoryName),
|
||||||
// Top edge gradient
|
// Top edge gradient (subtle forest green)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -501,14 +518,14 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
Colors.black.withAlpha(180),
|
const Color(0xFF1B4D3E).withAlpha(120),
|
||||||
Colors.black.withAlpha(0),
|
const Color(0xFF1B4D3E).withAlpha(0),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Bottom edge gradient
|
// Bottom edge gradient (subtle forest green)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -520,8 +537,8 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
Colors.black.withAlpha(0),
|
const Color(0xFF1B4D3E).withAlpha(0),
|
||||||
Colors.black.withAlpha(200),
|
const Color(0xFF1B4D3E).withAlpha(150),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
237
lib/services/stripe_service.dart
Normal file
237
lib/services/stripe_service.dart
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_stripe/flutter_stripe.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'api.dart';
|
||||||
|
|
||||||
|
class PaymentResult {
|
||||||
|
final bool success;
|
||||||
|
final String? paymentIntentId;
|
||||||
|
final String? error;
|
||||||
|
final FeeBreakdown? feeBreakdown;
|
||||||
|
|
||||||
|
const PaymentResult({
|
||||||
|
required this.success,
|
||||||
|
this.paymentIntentId,
|
||||||
|
this.error,
|
||||||
|
this.feeBreakdown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeeBreakdown {
|
||||||
|
final double subtotal;
|
||||||
|
final double tax;
|
||||||
|
final double tip;
|
||||||
|
final double payfritFee;
|
||||||
|
final double cardFee;
|
||||||
|
final double total;
|
||||||
|
|
||||||
|
const FeeBreakdown({
|
||||||
|
required this.subtotal,
|
||||||
|
required this.tax,
|
||||||
|
required this.tip,
|
||||||
|
required this.payfritFee,
|
||||||
|
required this.cardFee,
|
||||||
|
required this.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FeeBreakdown.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FeeBreakdown(
|
||||||
|
subtotal: (json['SUBTOTAL'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
tax: (json['TAX'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
tip: (json['TIP'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
payfritFee: (json['PAYFRIT_FEE'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
cardFee: (json['CARD_FEE'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
total: (json['TOTAL'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StripeService {
|
||||||
|
static bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// Initialize Stripe with publishable key
|
||||||
|
static Future<void> initialize(String publishableKey) async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
Stripe.publishableKey = publishableKey;
|
||||||
|
await Stripe.instance.applySettings();
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create payment intent on the server
|
||||||
|
static Future<Map<String, dynamic>> _createPaymentIntent({
|
||||||
|
required int businessId,
|
||||||
|
required int orderId,
|
||||||
|
required double subtotal,
|
||||||
|
required double tax,
|
||||||
|
double tip = 0.0,
|
||||||
|
String? customerEmail,
|
||||||
|
}) async {
|
||||||
|
final url = Uri.parse('${Api.baseUrl}/stripe/createPaymentIntent.cfm');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json; charset=utf-8'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'BusinessID': businessId,
|
||||||
|
'OrderID': orderId,
|
||||||
|
'Subtotal': subtotal,
|
||||||
|
'Tax': tax,
|
||||||
|
'Tip': tip,
|
||||||
|
if (customerEmail != null) 'CustomerEmail': customerEmail,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final body = response.body;
|
||||||
|
Map<String, dynamic>? json;
|
||||||
|
|
||||||
|
try {
|
||||||
|
json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
// Try to extract JSON from body (handles debug output)
|
||||||
|
final jsonStart = body.indexOf('{');
|
||||||
|
final jsonEnd = body.lastIndexOf('}');
|
||||||
|
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
||||||
|
try {
|
||||||
|
json = jsonDecode(body.substring(jsonStart, jsonEnd + 1)) as Map<String, dynamic>;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json == null) {
|
||||||
|
throw StateError('Failed to parse payment intent response');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json['OK'] != true) {
|
||||||
|
throw StateError(json['ERROR']?.toString() ?? 'Payment intent creation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate fee breakdown without creating payment intent
|
||||||
|
/// Useful for displaying fees before payment
|
||||||
|
static FeeBreakdown calculateFees({
|
||||||
|
required double subtotal,
|
||||||
|
required double tax,
|
||||||
|
double tip = 0.0,
|
||||||
|
}) {
|
||||||
|
const customerFeePercent = 0.05; // 5% Payfrit fee
|
||||||
|
const cardFeePercent = 0.029; // 2.9% Stripe fee
|
||||||
|
const cardFeeFixed = 0.30; // $0.30 Stripe fixed fee
|
||||||
|
|
||||||
|
final payfritFee = subtotal * customerFeePercent;
|
||||||
|
final totalBeforeCardFee = subtotal + tax + tip + payfritFee;
|
||||||
|
final cardFee = (totalBeforeCardFee * cardFeePercent) + cardFeeFixed;
|
||||||
|
final total = totalBeforeCardFee + cardFee;
|
||||||
|
|
||||||
|
return FeeBreakdown(
|
||||||
|
subtotal: subtotal,
|
||||||
|
tax: tax,
|
||||||
|
tip: tip,
|
||||||
|
payfritFee: payfritFee,
|
||||||
|
cardFee: cardFee,
|
||||||
|
total: total,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process payment for an order
|
||||||
|
static Future<PaymentResult> processPayment({
|
||||||
|
required BuildContext context,
|
||||||
|
required int businessId,
|
||||||
|
required int orderId,
|
||||||
|
required double subtotal,
|
||||||
|
required double tax,
|
||||||
|
double tip = 0.0,
|
||||||
|
String? customerEmail,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
print('[Stripe] Starting payment process...');
|
||||||
|
print('[Stripe] BusinessID=$businessId, OrderID=$orderId, Subtotal=$subtotal, Tax=$tax, Tip=$tip');
|
||||||
|
|
||||||
|
// 1. Create payment intent on server
|
||||||
|
final intentData = await _createPaymentIntent(
|
||||||
|
businessId: businessId,
|
||||||
|
orderId: orderId,
|
||||||
|
subtotal: subtotal,
|
||||||
|
tax: tax,
|
||||||
|
tip: tip,
|
||||||
|
customerEmail: customerEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
print('[Stripe] Payment intent response: $intentData');
|
||||||
|
|
||||||
|
final clientSecret = intentData['CLIENT_SECRET'] as String?;
|
||||||
|
final publishableKey = intentData['PUBLISHABLE_KEY'] as String?;
|
||||||
|
final paymentIntentId = intentData['PAYMENT_INTENT_ID'] as String?;
|
||||||
|
final feeData = intentData['FEE_BREAKDOWN'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
if (clientSecret == null || clientSecret.isEmpty) {
|
||||||
|
print('[Stripe] ERROR: No client secret in response');
|
||||||
|
return const PaymentResult(
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create payment intent',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[Stripe] Client secret received, publishable key: $publishableKey');
|
||||||
|
|
||||||
|
// 2. Initialize Stripe if needed
|
||||||
|
if (publishableKey != null && publishableKey.isNotEmpty) {
|
||||||
|
print('[Stripe] Initializing Stripe with key: ${publishableKey.substring(0, 20)}...');
|
||||||
|
await initialize(publishableKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
print('[Stripe] Initializing payment sheet...');
|
||||||
|
|
||||||
|
// 3. Present payment sheet
|
||||||
|
await Stripe.instance.initPaymentSheet(
|
||||||
|
paymentSheetParameters: SetupPaymentSheetParameters(
|
||||||
|
merchantDisplayName: 'Payfrit',
|
||||||
|
paymentIntentClientSecret: clientSecret,
|
||||||
|
style: ThemeMode.system,
|
||||||
|
appearance: const PaymentSheetAppearance(
|
||||||
|
colors: PaymentSheetAppearanceColors(
|
||||||
|
primary: Color(0xFF000000),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
print('[Stripe] Payment sheet initialized, presenting...');
|
||||||
|
|
||||||
|
await Stripe.instance.presentPaymentSheet();
|
||||||
|
|
||||||
|
print('[Stripe] Payment successful!');
|
||||||
|
|
||||||
|
// 4. Payment successful
|
||||||
|
return PaymentResult(
|
||||||
|
success: true,
|
||||||
|
paymentIntentId: paymentIntentId,
|
||||||
|
feeBreakdown: feeData != null ? FeeBreakdown.fromJson(feeData) : null,
|
||||||
|
);
|
||||||
|
} on StripeException catch (e) {
|
||||||
|
print('[Stripe] StripeException: ${e.error.code} - ${e.error.message} - ${e.error.localizedMessage}');
|
||||||
|
// User cancelled or payment failed
|
||||||
|
if (e.error.code == FailureCode.Canceled) {
|
||||||
|
return const PaymentResult(
|
||||||
|
success: false,
|
||||||
|
error: 'Payment cancelled',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return PaymentResult(
|
||||||
|
success: false,
|
||||||
|
error: e.error.localizedMessage ?? 'Payment failed',
|
||||||
|
);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
print('[Stripe] Exception: $e');
|
||||||
|
print('[Stripe] Stack trace: $stackTrace');
|
||||||
|
return PaymentResult(
|
||||||
|
success: false,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
pubspec.lock
40
pubspec.lock
|
|
@ -134,6 +134,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
flutter_stripe:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_stripe
|
||||||
|
sha256: a474b283f4b07e8973687514bf48762e618073b0d6b7acc45cea9a60466d4f8c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.5.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -144,6 +152,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
freezed_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: freezed_annotation
|
||||||
|
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.4"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -453,6 +469,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
stripe_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stripe_android
|
||||||
|
sha256: a666352e0c20753ecd8feebb5944882bf597167be4f020641266515a495bd55f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.5.0"
|
||||||
|
stripe_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stripe_ios
|
||||||
|
sha256: "0f7afed3ac61e544e7525da9b692b23d93e762d56f6c9aa7f77fc6d9a686a65d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.5.0"
|
||||||
|
stripe_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stripe_platform_interface
|
||||||
|
sha256: "23c10f3875da07f85a6196fcb676e64c767ad2d04ec73ba4e941ac797a4ee4d3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.5.0"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ dependencies:
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
shared_preferences: ^2.2.3
|
shared_preferences: ^2.2.3
|
||||||
dchs_flutter_beacon: ^0.6.6
|
dchs_flutter_beacon: ^0.6.6
|
||||||
|
flutter_stripe: ^11.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue