skills/app-store-submission-auditor/references/flutter-patterns.md

6.2 KiB

Flutter-Specific App Store Patterns

Reference file for the auditor. Load this when the user's stack is Flutter, React Native, or other cross-platform frameworks.


Location (Flutter)

Package: geolocator, location, or permission_handler

Rejection pattern — permission requested after access

// BAD: accessing location before requesting permission
Position position = await Geolocator.getCurrentPosition();

// BAD: calling permission check and immediately using location
await Geolocator.checkPermission();
Position position = await Geolocator.getCurrentPosition(); // no await for request

Correct pattern

// GOOD: request first, check result, then access
LocationPermission permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) return;
Position position = await Geolocator.getCurrentPosition();

Info.plist strings (still required even in Flutter)

Flutter apps still require NSLocationWhenInUseUsageDescription in ios/Runner/Info.plist. Check ios/Runner/Info.plist — NOT pubspec.yaml.

Bad string

<key>NSLocationWhenInUseUsageDescription</key>
<string>This app requires location access.</string>

Good string

<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location shows listings near you and displays your city on your profile. Never shared without your consent.</string>

Account Deletion (Flutter)

Rejection pattern — email-based deletion

// BAD: opening email client
final Uri emailUri = Uri(scheme: 'mailto', path: 'support@app.com', ...);
launchUrl(emailUri);

// BAD: just navigating to a web form
launchUrl(Uri.parse('https://myapp.com/delete-account'));

Correct pattern

// GOOD: direct API call from within the app
Future<void> deleteAccount() async {
  final confirmed = await showConfirmationDialog(context);
  if (!confirmed) return;
  
  await authService.deleteAccount(); // triggers server-side deletion
  await FirebaseAuth.instance.currentUser?.delete();
  // then sign out and navigate to onboarding
}

AWS Cognito + Firebase note: Both need to be deleted — the Cognito user pool entry AND the Firebase Auth user. Deleting only one will leave orphaned data and may cause issues on re-registration.


Block User (Flutter)

Rejection pattern — report only, no block

// BAD: only report available
void reportUser(String userId) {
  firestoreService.reportUser(userId);
  showSnackbar('User reported');
}
// No block functionality anywhere

Correct pattern

// GOOD: separate block action
Future<void> blockUser(String targetUserId) async {
  await firestoreService.addToBlockList(
    currentUserId: currentUser.id,
    blockedUserId: targetUserId,
  );
  // Update local state immediately so UI reflects block
}

// Block must be surfaced in BOTH places:
// 1. User profile screen — context menu or button
// 2. Chat screen — message long-press or header menu

Firestore security rules must enforce block:

// Blocked users must not be able to read or write to each other's data
allow read: if !isBlockedBy(request.auth.uid, resource.data.userId);

In-App Purchases / Subscriptions (Flutter)

Package: in_app_purchase, purchases_flutter (RevenueCat), qonversion

Rejection pattern — wrong payment method for digital goods

// BAD: Stripe for a subscription
final result = await Stripe.instance.confirmPayment(...);

// BAD: custom credits system that bypasses IAP for digital features
await creditsService.purchaseCredits(amount: 100); // if credits unlock digital content

Correct pattern

// GOOD: StoreKit via in_app_purchase or RevenueCat
final CustomerInfo customerInfo = await Purchases.purchasePackage(package);

Credits system note: If credits are used to unlock digital features (messaging, boosts, premium listings), they must go through Apple IAP. Credits for physical goods (like escrow for swaps) are generally fine outside IAP.

Missing restore purchases

// Must exist somewhere in your UI — usually Settings
await InAppPurchase.instance.restorePurchases();

Privacy / Permission Strings (Flutter → Info.plist)

All permission strings live in ios/Runner/Info.plist. Flutter's pubspec.yaml permissions config does NOT replace these.

Common keys to check:

NSLocationWhenInUseUsageDescription   <!-- if using location -->
NSCameraUsageDescription              <!-- if using camera -->
NSPhotoLibraryUsageDescription        <!-- if accessing photo library -->
NSMicrophoneUsageDescription          <!-- if using microphone -->
NSContactsUsageDescription            <!-- if accessing contacts -->
NSUserTrackingUsageDescription        <!-- if using ATT/IDFA -->

Each must have a specific, honest description. Empty strings or generic text = rejection.


IPv6 / Network (Flutter)

Package: dio, http

Rejection pattern — hardcoded IPs

// BAD: hardcoded IPv4
static const String baseUrl = 'http://192.168.1.100:8080';
static const String apiUrl = 'http://54.201.12.34/api/v1';

// BAD: http instead of https in production
static const String baseUrl = 'http://myapp.com/api'; // should be https

Correct pattern

// GOOD: domain name + https
static const String baseUrl = 'https://api.myapp.com/v1';

Firebase + AWS note: Firebase SDK and AWS Amplify connect via domain names internally — these are fine. Only custom API base URLs are at risk.


Metadata / App Store Connect (Flutter)

  • Screenshots must be taken from the actual running app — not Figma mockups placed on device frames
  • Flutter's hot reload can cause visual glitches in screenshots — take them from a clean cold launch
  • iPad screenshots: Flutter apps need iPad screenshots even if not iPad-optimized. Use a simulator.

TestFlight vs App Store (Flutter)

Flutter-specific gotcha: Firebase App Distribution ≠ TestFlight. Apple only recognizes TestFlight for beta distribution. Passing Firebase distribution does not mean you'll pass App Store review.

Also: Flutter's --release build can behave differently from --debug or --profile. Always test the release build on a physical device before submitting.