Mobile Applications Guide
Build powerful mobile trading card applications using the Trading Card API. This guide covers platform-specific implementations, mobile optimizations, and best practices for creating engaging mobile experiences.
Prerequisites
- Trading Card API access credentials
- Mobile development environment set up
- Basic understanding of your chosen platform (iOS, Android, React Native, or Flutter)
- Knowledge of REST API integration
Architecture Overview
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Mobile App │ │ Trading Card │ │ Local Cache │
│ │◄───┤ API │ │ │
│ - UI Layer │ │ │ │ - SQLite │
│ - Business │ │ - Cards │ │ - Images │
│ - Data Layer │ │ - Sets │ │ - Metadata │
│ │ │ - Players │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
Step 1: Mobile-Specific API Considerations
Optimized Data Requests
// Request only necessary fields for mobile
const params = {
'fields[cards]': 'name,year,number,image_url_small',
'page[limit]': 20,
'include': 'set'
};
Image Optimization
// Use appropriately sized images for mobile
const getOptimizedImageUrl = (card, screenDensity) => {
const baseUrl = card.attributes.image_url;
const size = screenDensity > 2 ? 'large' : 'medium';
return `${baseUrl}?size=${size}&format=webp`;
};
Step 2: iOS Implementation (Swift/SwiftUI)
API Client Setup
import Foundation
import Combine
class TradingCardAPIClient: ObservableObject {
private let baseURL = "https://api.tradingcardapi.com"
private let clientId: String
private let clientSecret: String
init(clientId: String, clientSecret: String) {
self.clientId = clientId
self.clientSecret = clientSecret
}
func fetchCards(page: Int = 1, limit: Int = 20) -> AnyPublisher<CardResponse, Error> {
var components = URLComponents(string: "\(baseURL)/cards")!
components.queryItems = [
URLQueryItem(name: "page[number]", value: "\(page)"),
URLQueryItem(name: "page[limit]", value: "\(limit)"),
URLQueryItem(name: "fields[cards]", value: "name,year,number,image_url_small")
]
var request = URLRequest(url: components.url!)
request.setValue("Bearer \(getAccessToken())", forHTTPHeaderField: "Authorization")
return URLSession.shared
.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: CardResponse.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
private func getAccessToken() -> String {
// Implement OAuth 2.0 token retrieval
return "your_access_token"
}
}
SwiftUI Card List View
import SwiftUI
struct CardListView: View {
@StateObject private var apiClient = TradingCardAPIClient(
clientId: "your_client_id",
clientSecret: "your_client_secret"
)
@State private var cards: [Card] = []
@State private var isLoading = false
var body: some View {
NavigationView {
List(cards) { card in
CardRowView(card: card)
.onAppear {
if card == cards.last {
loadMoreCards()
}
}
}
.navigationTitle("Trading Cards")
.onAppear(perform: loadCards)
.refreshable {
await refreshCards()
}
}
}
private func loadCards() {
isLoading = true
apiClient.fetchCards()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { _ in isLoading = false },
receiveValue: { response in
cards = response.data
}
)
.store(in: &cancellables)
}
}
struct CardRowView: View {
let card: Card
var body: some View {
HStack {
AsyncImage(url: URL(string: card.attributes.imageUrlSmall)) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.3))
}
.frame(width: 60, height: 84)
VStack(alignment: .leading, spacing: 4) {
Text(card.attributes.name)
.font(.headline)
Text("\(card.attributes.year)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
Local Data Persistence
import CoreData
class PersistenceController {
static let shared = PersistenceController()
lazy var container: NSPersistentContainer = {
let container = NSPersistentContainer(name: "TradingCards")
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Core Data error: \(error)")
}
}
return container
}()
func save() {
let context = container.viewContext
if context.hasChanges {
try? context.save()
}
}
}
// Cache API responses locally
extension TradingCardAPIClient {
func cacheCards(_ cards: [Card]) {
let context = PersistenceController.shared.container.viewContext
for card in cards {
let cachedCard = CachedCard(context: context)
cachedCard.id = card.id
cachedCard.name = card.attributes.name
cachedCard.year = Int16(card.attributes.year)
cachedCard.imageUrl = card.attributes.imageUrlSmall
cachedCard.lastUpdated = Date()
}
PersistenceController.shared.save()
}
}
Step 3: Android Implementation (Kotlin)
API Client with Retrofit
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
interface TradingCardAPI {
@GET("cards")
suspend fun getCards(
@Query("page[number]") page: Int = 1,
@Query("page[limit]") limit: Int = 20,
@Query("fields[cards]") fields: String = "name,year,number,image_url_small",
@Query("include") include: String? = null
): CardResponse
@GET("cards/{id}")
suspend fun getCard(
@Path("id") cardId: String,
@Query("include") include: String? = null
): CardDetailResponse
}
class TradingCardRepository {
private val api: TradingCardAPI
init {
val retrofit = Retrofit.Builder()
.baseUrl("https://api.tradingcardapi.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
api = retrofit.create(TradingCardAPI::class.java)
}
suspend fun fetchCards(page: Int = 1): Result<CardResponse> {
return try {
val response = api.getCards(page = page, limit = 20)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
}
Jetpack Compose UI
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CardListScreen(
viewModel: CardListViewModel = hiltViewModel()
) {
val cards by viewModel.cards.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadCards()
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(cards) { card ->
CardListItem(
card = card,
onClick = { viewModel.onCardClick(card.id) }
)
}
if (isLoading) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
fun CardListItem(
card: Card,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
AsyncImage(
model = card.attributes.imageUrlSmall,
contentDescription = card.attributes.name,
modifier = Modifier.size(60.dp, 84.dp)
)
Column {
Text(
text = card.attributes.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = "${card.attributes.year}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Local Database with Room
import androidx.room.*
@Entity(tableName = "cached_cards")
data class CachedCard(
@PrimaryKey val id: String,
val name: String,
val year: Int,
val imageUrl: String,
val lastUpdated: Long = System.currentTimeMillis()
)
@Dao
interface CardDao {
@Query("SELECT * FROM cached_cards ORDER BY name ASC")
fun getAllCards(): Flow<List<CachedCard>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCards(cards: List<CachedCard>)
@Query("DELETE FROM cached_cards WHERE lastUpdated < :timestamp")
suspend fun deleteOldCards(timestamp: Long)
}
@Database(
entities = [CachedCard::class],
version = 1,
exportSchema = false
)
abstract class TradingCardDatabase : RoomDatabase() {
abstract fun cardDao(): CardDao
}
Step 4: React Native Cross-Platform Implementation
API Service
import AsyncStorage from '@react-native-async-storage/async-storage';
class TradingCardService {
constructor(clientId, clientSecret) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.baseUrl = 'https://api.tradingcardapi.com';
this.accessToken = null;
}
async authenticate() {
try {
const cachedToken = await AsyncStorage.getItem('access_token');
if (cachedToken) {
this.accessToken = cachedToken;
return;
}
const response = await fetch(`${this.baseUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret
})
});
const data = await response.json();
this.accessToken = data.access_token;
await AsyncStorage.setItem('access_token', this.accessToken);
} catch (error) {
throw new Error('Authentication failed');
}
}
async fetchCards(page = 1, limit = 20) {
await this.authenticate();
const params = new URLSearchParams({
'page[number]': page,
'page[limit]': limit,
'fields[cards]': 'name,year,number,image_url_small,set'
});
const response = await fetch(`${this.baseUrl}/cards?${params}`, {
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Accept': 'application/vnd.api+json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch cards');
}
return await response.json();
}
}
React Native Component
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
FlatList,
Image,
TouchableOpacity,
StyleSheet,
RefreshControl,
ActivityIndicator
} from 'react-native';
const CardListScreen = ({ navigation }) => {
const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const apiService = new TradingCardService(
'your_client_id',
'your_client_secret'
);
const loadCards = useCallback(async (pageNum = 1, isRefresh = false) => {
if (loading && !isRefresh) return;
setLoading(true);
try {
const response = await apiService.fetchCards(pageNum, 20);
const newCards = response.data;
if (isRefresh) {
setCards(newCards);
setPage(2);
} else {
setCards(prev => [...prev, ...newCards]);
setPage(pageNum + 1);
}
setHasMore(newCards.length === 20);
} catch (error) {
console.error('Failed to load cards:', error);
} finally {
setLoading(false);
if (isRefresh) setRefreshing(false);
}
}, [loading]);
useEffect(() => {
loadCards();
}, []);
const onRefresh = useCallback(() => {
setRefreshing(true);
loadCards(1, true);
}, [loadCards]);
const loadMore = useCallback(() => {
if (hasMore && !loading) {
loadCards(page);
}
}, [hasMore, loading, page, loadCards]);
const renderCard = ({ item: card }) => (
<TouchableOpacity
style={styles.cardItem}
onPress={() => navigation.navigate('CardDetail', { cardId: card.id })}
>
<Image
source={{ uri: card.attributes.image_url_small }}
style={styles.cardImage}
resizeMode="contain"
/>
<View style={styles.cardInfo}>
<Text style={styles.cardName}>{card.attributes.name}</Text>
<Text style={styles.cardYear}>{card.attributes.year}</Text>
{card.attributes.number && (
<Text style={styles.cardNumber}>#{card.attributes.number}</Text>
)}
</View>
</TouchableOpacity>
);
return (
<View style={styles.container}>
<FlatList
data={cards}
renderItem={renderCard}
keyExtractor={(card) => card.id}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={() =>
loading ? <ActivityIndicator style={styles.loader} /> : null
}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
cardItem: {
flexDirection: 'row',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
cardImage: {
width: 60,
height: 84,
marginRight: 12,
},
cardInfo: {
flex: 1,
justifyContent: 'center',
},
cardName: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 4,
},
cardYear: {
fontSize: 14,
color: '#666',
marginBottom: 2,
},
cardNumber: {
fontSize: 12,
color: '#999',
},
loader: {
padding: 20,
},
});
Offline Support with AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage';
class OfflineCardStorage {
static async cacheCards(cards, page = 1) {
try {
const key = `cards_page_${page}`;
const data = {
cards,
timestamp: Date.now(),
page
};
await AsyncStorage.setItem(key, JSON.stringify(data));
} catch (error) {
console.error('Failed to cache cards:', error);
}
}
static async getCachedCards(page = 1) {
try {
const key = `cards_page_${page}`;
const cached = await AsyncStorage.getItem(key);
if (cached) {
const data = JSON.parse(cached);
const isStale = Date.now() - data.timestamp > 24 * 60 * 60 * 1000; // 24 hours
if (!isStale) {
return data.cards;
}
}
return null;
} catch (error) {
console.error('Failed to get cached cards:', error);
return null;
}
}
static async clearCache() {
try {
const keys = await AsyncStorage.getAllKeys();
const cardKeys = keys.filter(key => key.startsWith('cards_page_'));
await AsyncStorage.multiRemove(cardKeys);
} catch (error) {
console.error('Failed to clear cache:', error);
}
}
}
Step 5: Flutter Implementation
API Service
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class TradingCardApiService {
final String baseUrl = 'https://api.tradingcardapi.com';
final String clientId;
final String clientSecret;
String? _accessToken;
TradingCardApiService({
required this.clientId,
required this.clientSecret,
});
Future<void> authenticate() async {
final prefs = await SharedPreferences.getInstance();
_accessToken = prefs.getString('access_token');
if (_accessToken != null) return;
final response = await http.post(
Uri.parse('$baseUrl/oauth/token'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'grant_type': 'client_credentials',
'client_id': clientId,
'client_secret': clientSecret,
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_accessToken = data['access_token'];
await prefs.setString('access_token', _accessToken!);
} else {
throw Exception('Authentication failed');
}
}
Future<Map<String, dynamic>> fetchCards({
int page = 1,
int limit = 20,
}) async {
await authenticate();
final uri = Uri.parse('$baseUrl/cards').replace(queryParameters: {
'page[number]': page.toString(),
'page[limit]': limit.toString(),
'fields[cards]': 'name,year,number,image_url_small',
});
final response = await http.get(
uri,
headers: {
'Authorization': 'Bearer $_accessToken',
'Accept': 'application/vnd.api+json',
},
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to fetch cards');
}
}
}
Flutter UI Components
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
class CardListPage extends StatefulWidget {
@override
_CardListPageState createState() => _CardListPageState();
}
class _CardListPageState extends State<CardListPage> {
final TradingCardApiService _apiService = TradingCardApiService(
clientId: 'your_client_id',
clientSecret: 'your_client_secret',
);
List<dynamic> _cards = [];
bool _isLoading = false;
int _currentPage = 1;
bool _hasMore = true;
@override
void initState() {
super.initState();
_loadCards();
}
Future<void> _loadCards({bool isRefresh = false}) async {
if (_isLoading) return;
setState(() => _isLoading = true);
try {
final page = isRefresh ? 1 : _currentPage;
final response = await _apiService.fetchCards(page: page);
final newCards = response['data'] as List;
setState(() {
if (isRefresh) {
_cards = newCards;
_currentPage = 2;
} else {
_cards.addAll(newCards);
_currentPage++;
}
_hasMore = newCards.length == 20;
});
} catch (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load cards: $error')),
);
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Trading Cards'),
actions: [
IconButton(
icon: Icon(Icons.search),
onPressed: () => Navigator.pushNamed(context, '/search'),
),
],
),
body: RefreshIndicator(
onRefresh: () => _loadCards(isRefresh: true),
child: ListView.builder(
itemCount: _cards.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _cards.length) {
return _buildLoader();
}
final card = _cards[index];
return _buildCardTile(card);
},
),
),
);
}
Widget _buildCardTile(dynamic card) {
final attributes = card['attributes'];
return ListTile(
leading: CachedNetworkImage(
imageUrl: attributes['image_url_small'] ?? '',
width: 60,
height: 84,
fit: BoxFit.contain,
placeholder: (context, url) => Container(
width: 60,
height: 84,
color: Colors.grey[300],
child: Icon(Icons.image, color: Colors.grey[600]),
),
errorWidget: (context, url, error) => Container(
width: 60,
height: 84,
color: Colors.grey[300],
child: Icon(Icons.error, color: Colors.red),
),
),
title: Text(
attributes['name'] ?? 'Unknown Card',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text('${attributes['year'] ?? 'Unknown Year'}'),
onTap: () {
Navigator.pushNamed(
context,
'/card-detail',
arguments: card['id'],
);
},
);
}
Widget _buildLoader() {
if (!_isLoading) {
_loadCards();
}
return Container(
padding: EdgeInsets.all(16),
alignment: Alignment.center,
child: CircularProgressIndicator(),
);
}
}
Step 6: Camera Integration for Card Scanning
iOS Camera Implementation
import AVFoundation
import Vision
class CardScannerViewController: UIViewController {
private var captureSession: AVCaptureSession!
private var previewLayer: AVCaptureVideoPreviewLayer!
override func viewDidLoad() {
super.viewDidLoad()
setupCamera()
}
private func setupCamera() {
captureSession = AVCaptureSession()
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return }
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch {
return
}
if captureSession.canAddInput(videoInput) {
captureSession.addInput(videoInput)
}
let videoOutput = AVCaptureVideoDataOutput()
videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
if captureSession.canAddOutput(videoOutput) {
captureSession.addOutput(videoOutput)
}
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer.frame = view.layer.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
captureSession.startRunning()
}
}
extension CardScannerViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let request = VNRecognizeTextRequest { [weak self] request, error in
guard let observations = request.results as? [VNRecognizedTextObservation] else { return }
DispatchQueue.main.async {
self?.processTextObservations(observations)
}
}
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
try? handler.perform([request])
}
private func processTextObservations(_ observations: [VNRecognizedTextObservation]) {
let recognizedStrings = observations.compactMap { observation in
observation.topCandidates(1).first?.string
}
// Process recognized text to identify card information
searchForCard(with: recognizedStrings)
}
private func searchForCard(with textElements: [String]) {
// Use text elements to search Trading Card API
let searchTerms = textElements.joined(separator: " ")
// Implement API search with recognized text
}
}
Android Camera Implementation
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
class CardScannerActivity : AppCompatActivity() {
private lateinit var cameraExecutor: ExecutorService
private val textRecognizer = TextRecognition.getClient()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_card_scanner)
cameraExecutor = Executors.newSingleThreadExecutor()
startCamera()
}
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build()
val imageAnalyzer = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
imageAnalyzer.setAnalyzer(cameraExecutor) { imageProxy ->
processImage(imageProxy)
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
this,
cameraSelector,
preview,
imageAnalyzer
)
preview.setSurfaceProvider(viewBinding.previewView.surfaceProvider)
} catch (exc: Exception) {
Log.e(TAG, "Camera binding failed", exc)
}
}, ContextCompat.getMainExecutor(this))
}
@androidx.camera.core.ExperimentalGetImage
private fun processImage(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
textRecognizer.process(image)
.addOnSuccessListener { visionText ->
processRecognizedText(visionText.text)
}
.addOnFailureListener { e ->
Log.e(TAG, "Text recognition failed", e)
}
.addOnCompleteListener {
imageProxy.close()
}
}
}
private fun processRecognizedText(text: String) {
// Parse recognized text and search for matching cards
lifecycleScope.launch {
try {
val searchResults = apiService.searchCards(text)
runOnUiThread {
displaySearchResults(searchResults)
}
} catch (e: Exception) {
Log.e(TAG, "Card search failed", e)
}
}
}
}
Step 7: Push Notifications
iOS Push Notifications
import UserNotifications
class NotificationManager: NSObject, ObservableObject {
override init() {
super.init()
requestPermission()
}
func requestPermission() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
}
func scheduleLocalNotification(for card: Card, currentPrice: Double, targetPrice: Double) {
let content = UNMutableNotificationContent()
content.title = "Price Alert"
content.body = "\(card.attributes.name) is now $\(currentPrice) (target: $\(targetPrice))"
content.sound = UNNotificationSound.default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: "price-alert-\(card.id)",
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request)
}
}
// In AppDelegate
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
// Send device token to your backend for push notifications
registerDeviceToken(tokenString)
}
private func registerDeviceToken(_ token: String) {
// Register device token with your backend service
let parameters = [
"device_token": token,
"platform": "ios"
]
// Send to your notification service
}
Android Push Notifications
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
class TradingCardMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
remoteMessage.notification?.let { notification ->
showNotification(notification.title, notification.body)
}
// Handle data payload
remoteMessage.data.isNotEmpty().let {
handleDataPayload(remoteMessage.data)
}
}
override fun onNewToken(token: String) {
super.onNewToken(token)
sendTokenToServer(token)
}
private fun sendTokenToServer(token: String) {
// Send token to your backend for push notifications
val repository = NotificationRepository()
repository.registerDevice(token, "android")
}
private fun showNotification(title: String?, body: String?) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notification = NotificationCompat.Builder(this, "price_alerts")
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true)
.build()
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
}
private fun handleDataPayload(data: Map<String, String>) {
when (data["type"]) {
"price_alert" -> {
val cardId = data["card_id"]
val newPrice = data["price"]
// Handle price alert
}
"new_set_release" -> {
val setId = data["set_id"]
// Handle new set notification
}
}
}
}
Step 8: Offline Functionality and Data Synchronization
iOS Offline Implementation
import SQLite3
class OfflineDataManager {
private var db: OpaquePointer?
init() {
openDatabase()
createTables()
}
private func openDatabase() {
let fileURL = try! FileManager.default
.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
.appendingPathComponent("TradingCards.sqlite")
if sqlite3_open(fileURL.path, &db) == SQLITE_OK {
print("Successfully opened connection to database")
} else {
print("Unable to open database")
}
}
private func createTables() {
let createTableSQL = """
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
year INTEGER,
image_url TEXT,
last_updated INTEGER,
sync_status TEXT DEFAULT 'synced'
);
"""
if sqlite3_exec(db, createTableSQL, nil, nil, nil) == SQLITE_OK {
print("Cards table created successfully")
}
}
func cacheCards(_ cards: [Card]) {
for card in cards {
let insertSQL = """
INSERT OR REPLACE INTO cards (id, name, year, image_url, last_updated)
VALUES (?, ?, ?, ?, ?);
"""
var statement: OpaquePointer?
if sqlite3_prepare_v2(db, insertSQL, -1, &statement, nil) == SQLITE_OK {
sqlite3_bind_text(statement, 1, card.id, -1, nil)
sqlite3_bind_text(statement, 2, card.attributes.name, -1, nil)
sqlite3_bind_int(statement, 3, Int32(card.attributes.year))
sqlite3_bind_text(statement, 4, card.attributes.imageUrlSmall, -1, nil)
sqlite3_bind_int64(statement, 5, Int64(Date().timeIntervalSince1970))
if sqlite3_step(statement) == SQLITE_DONE {
print("Card cached successfully")
}
}
sqlite3_finalize(statement)
}
}
func getCachedCards() -> [Card] {
var cards: [Card] = []
let querySQL = "SELECT id, name, year, image_url FROM cards ORDER BY name;"
var statement: OpaquePointer?
if sqlite3_prepare_v2(db, querySQL, -1, &statement, nil) == SQLITE_OK {
while sqlite3_step(statement) == SQLITE_ROW {
let id = String(cString: sqlite3_column_text(statement, 0))
let name = String(cString: sqlite3_column_text(statement, 1))
let year = sqlite3_column_int(statement, 2)
let imageUrl = String(cString: sqlite3_column_text(statement, 3))
let card = Card(
id: id,
attributes: CardAttributes(
name: name,
year: Int(year),
imageUrlSmall: imageUrl
)
)
cards.append(card)
}
}
sqlite3_finalize(statement)
return cards
}
}
Background Sync Strategy
// React Native background sync
import BackgroundJob from 'react-native-background-job';
import NetInfo from '@react-native-community/netinfo';
class BackgroundSyncManager {
static startBackgroundSync() {
BackgroundJob.register({
jobKey: 'cardDataSync',
period: 30000, // 30 seconds
});
BackgroundJob.on('cardDataSync', async () => {
const isConnected = await NetInfo.fetch().then(state => state.isConnected);
if (isConnected) {
await this.syncPendingChanges();
await this.updateCachedData();
}
});
BackgroundJob.start({
jobKey: 'cardDataSync',
});
}
static async syncPendingChanges() {
try {
const pendingChanges = await OfflineStorage.getPendingChanges();
for (const change of pendingChanges) {
switch (change.type) {
case 'favorite':
await apiService.updateFavorite(change.cardId, change.isFavorite);
break;
case 'collection_add':
await apiService.addToCollection(change.cardId);
break;
case 'price_alert':
await apiService.setPriceAlert(change.cardId, change.targetPrice);
break;
}
await OfflineStorage.markSynced(change.id);
}
} catch (error) {
console.error('Background sync failed:', error);
}
}
static async updateCachedData() {
try {
// Update cached data with fresh API data
const lastSync = await AsyncStorage.getItem('last_sync_timestamp');
const currentTime = Date.now();
// Only sync if it's been more than 1 hour
if (!lastSync || currentTime - parseInt(lastSync) > 3600000) {
const freshCards = await apiService.fetchCards(1, 50);
await OfflineStorage.updateCache(freshCards.data);
await AsyncStorage.setItem('last_sync_timestamp', currentTime.toString());
}
} catch (error) {
console.error('Cache update failed:', error);
}
}
}
Step 9: Performance Optimization
Image Caching and Loading
// React Native optimized image loading
import FastImage from 'react-native-fast-image';
const OptimizedCardImage = ({ card, size = 'small' }) => {
const getImageUri = () => {
const baseUrl = card.attributes.image_url_small;
return {
uri: baseUrl,
priority: FastImage.priority.normal,
cache: FastImage.cacheControl.immutable,
};
};
return (
<FastImage
style={getImageStyle(size)}
source={getImageUri()}
resizeMode={FastImage.resizeMode.contain}
fallback={true}
/>
);
};
const getImageStyle = (size) => {
const dimensions = {
small: { width: 60, height: 84 },
medium: { width: 120, height: 168 },
large: { width: 240, height: 336 }
};
return dimensions[size] || dimensions.small;
};
Memory Management
// Android memory-efficient loading
class CardImageLoader {
companion object {
private const val MAX_CACHE_SIZE = 50 * 1024 * 1024 // 50MB
val imageCache = LruCache<String, Bitmap>(MAX_CACHE_SIZE)
fun loadImage(
context: Context,
imageUrl: String,
imageView: ImageView,
onLoaded: (() -> Unit)? = null
) {
// Check cache first
imageCache.get(imageUrl)?.let { cachedBitmap ->
imageView.setImageBitmap(cachedBitmap)
onLoaded?.invoke()
return
}
// Load with Glide for memory efficiency
Glide.with(context)
.asBitmap()
.load(imageUrl)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.listener(object : RequestListener<Bitmap> {
override fun onResourceReady(
resource: Bitmap?,
model: Any?,
target: Target<Bitmap>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
resource?.let { bitmap ->
imageCache.put(imageUrl, bitmap)
}
onLoaded?.invoke()
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Bitmap>?,
isFirstResource: Boolean
): Boolean {
return false
}
})
.into(imageView)
}
}
}
API Request Batching
// Flutter request batching
class BatchRequestManager {
final List<String> _pendingCardIds = [];
Timer? _batchTimer;
static const Duration _batchDelay = Duration(milliseconds: 500);
void requestCard(String cardId) {
_pendingCardIds.add(cardId);
_batchTimer?.cancel();
_batchTimer = Timer(_batchDelay, _processBatch);
}
Future<void> _processBatch() async {
if (_pendingCardIds.isEmpty) return;
final cardIds = List<String>.from(_pendingCardIds);
_pendingCardIds.clear();
try {
final response = await http.get(
Uri.parse('https://api.tradingcardapi.com/cards').replace(
queryParameters: {
'filter[id]': cardIds.join(','),
'page[limit]': cardIds.length.toString(),
},
),
headers: {
'Authorization': 'Bearer $_accessToken',
'Accept': 'application/vnd.api+json',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final cards = data['data'] as List;
// Cache the results
await _cacheCards(cards);
// Notify listeners
_notifyCardsFetched(cards);
}
} catch (error) {
print('Batch request failed: $error');
}
}
Future<void> _cacheCards(List<dynamic> cards) async {
final prefs = await SharedPreferences.getInstance();
for (final card in cards) {
final key = 'card_${card['id']}';
await prefs.setString(key, jsonEncode(card));
}
}
void _notifyCardsFetched(List<dynamic> cards) {
// Implement notification logic for UI updates
}
}
Step 10: Location-Based Features
Find Local Card Shops
// React Native location services
import Geolocation from '@react-native-community/geolocation';
class LocationService {
static async findNearbyCardShops(radius = 25) {
return new Promise((resolve, reject) => {
Geolocation.getCurrentPosition(
async (position) => {
const { latitude, longitude } = position.coords;
try {
const response = await fetch(`${API_BASE_URL}/locations/card-shops`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
latitude,
longitude,
radius_miles: radius,
types: ['card_shop', 'game_store', 'comic_shop']
})
});
const shops = await response.json();
resolve(shops.data);
} catch (error) {
reject(error);
}
},
(error) => reject(error),
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 300000 // 5 minutes
}
);
});
}
static async findLocalEvents(eventTypes = ['tournament', 'trade_night']) {
const position = await this.getCurrentPosition();
const response = await fetch(`${API_BASE_URL}/events/nearby`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
latitude: position.latitude,
longitude: position.longitude,
radius_miles: 50,
event_types: eventTypes,
start_date: new Date().toISOString(),
end_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() // 30 days
})
});
return await response.json();
}
}
Map Integration
// iOS MapKit integration
import MapKit
class CardShopMapViewController: UIViewController {
@IBOutlet weak var mapView: MKMapView!
private let locationManager = CLLocationManager()
override func viewDidLoad() {
super.viewDidLoad()
setupMap()
requestLocationPermission()
}
private func setupMap() {
mapView.delegate = self
mapView.showsUserLocation = true
mapView.userTrackingMode = .none
}
private func requestLocationPermission() {
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
}
private func loadNearbyShops() {
guard let userLocation = locationManager.location else { return }
LocationService.findNearbyCardShops(
latitude: userLocation.coordinate.latitude,
longitude: userLocation.coordinate.longitude
) { [weak self] shops in
DispatchQueue.main.async {
self?.addShopsToMap(shops)
}
}
}
private func addShopsToMap(_ shops: [CardShop]) {
let annotations = shops.map { shop in
let annotation = MKPointAnnotation()
annotation.coordinate = CLLocationCoordinate2D(
latitude: shop.latitude,
longitude: shop.longitude
)
annotation.title = shop.name
annotation.subtitle = shop.address
return annotation
}
mapView.addAnnotations(annotations)
// Zoom to show all shops
let region = MKCoordinateRegion(
center: locationManager.location!.coordinate,
latitudinalMeters: 25000,
longitudinalMeters: 25000
)
mapView.setRegion(region, animated: true)
}
}
extension CardShopMapViewController: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
if status == .authorizedWhenInUse {
loadNearbyShops()
}
}
}
Mobile UX Best Practices
Touch-Friendly Interface Design
// React Native responsive design
const CardGrid = ({ cards, onCardPress }) => {
const screenWidth = Dimensions.get('window').width;
const cardWidth = (screenWidth - 48) / 2; // 2 columns with padding
return (
<FlatList
data={cards}
numColumns={2}
keyExtractor={(card) => card.id}
contentContainerStyle={styles.gridContainer}
renderItem={({ item: card }) => (
<TouchableOpacity
style={[styles.cardContainer, { width: cardWidth }]}
onPress={() => onCardPress(card)}
activeOpacity={0.8}
>
<Image
source={{ uri: card.attributes.image_url_small }}
style={styles.cardImage}
resizeMode="contain"
/>
<Text style={styles.cardName} numberOfLines={2}>
{card.attributes.name}
</Text>
<Text style={styles.cardYear}>{card.attributes.year}</Text>
</TouchableOpacity>
)}
/>
);
};
const styles = StyleSheet.create({
gridContainer: {
padding: 16,
},
cardContainer: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 12,
marginBottom: 16,
marginHorizontal: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardImage: {
width: '100%',
height: 120,
marginBottom: 8,
},
cardName: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 4,
},
cardYear: {
fontSize: 12,
color: '#666',
},
});
Search Interface for Mobile
// Android search with suggestions
class CardSearchActivity : AppCompatActivity() {
private lateinit var searchView: SearchView
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: CardSearchAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_card_search)
setupSearchView()
setupRecyclerView()
}
private fun setupSearchView() {
searchView = findViewById(R.id.search_view)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
query?.let { performSearch(it) }
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
newText?.let {
if (it.length >= 3) {
showSearchSuggestions(it)
}
}
return true
}
})
}
private fun performSearch(query: String) {
lifecycleScope.launch {
try {
val results = apiService.searchCards(
query = query,
limit = 50
)
adapter.updateCards(results.data)
} catch (error) {
showError("Search failed: ${error.message}")
}
}
}
private fun showSearchSuggestions(partialQuery: String) {
lifecycleScope.launch {
try {
val suggestions = apiService.getSearchSuggestions(partialQuery)
updateSearchSuggestions(suggestions)
} catch (error) {
// Handle suggestion errors silently
}
}
}
}
Authentication and Security
Secure Token Storage
// iOS Keychain storage
import Security
class KeychainManager {
static func save(key: String, data: Data) -> OSStatus {
let query = [
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrAccount as String: key,
kSecValueData as String: data
] as [String: Any]
SecItemDelete(query as CFDictionary)
return SecItemAdd(query as CFDictionary, nil)
}
static func load(key: String) -> Data? {
let query = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
] as [String: Any]
var dataTypeRef: AnyObject?
let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
if status == noErr {
return dataTypeRef as! Data?
} else {
return nil
}
}
static func saveAccessToken(_ token: String) {
if let tokenData = token.data(using: .utf8) {
save(key: "access_token", data: tokenData)
}
}
static func getAccessToken() -> String? {
guard let tokenData = load(key: "access_token") else { return nil }
return String(data: tokenData, encoding: .utf8)
}
}
Android Secure Storage
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
class SecureStorage(private val context: Context) {
private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
private val sharedPreferences = EncryptedSharedPreferences.create(
"trading_card_prefs",
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveAccessToken(token: String) {
sharedPreferences.edit()
.putString("access_token", token)
.apply()
}
fun getAccessToken(): String? {
return sharedPreferences.getString("access_token", null)
}
fun saveUserCredentials(clientId: String, clientSecret: String) {
sharedPreferences.edit()
.putString("client_id", clientId)
.putString("client_secret", clientSecret)
.apply()
}
fun clearAllData() {
sharedPreferences.edit().clear().apply()
}
}
Augmented Reality Integration
iOS ARKit Implementation
import ARKit
import SceneKit
class ARCardViewController: UIViewController {
@IBOutlet var sceneView: ARSCNView!
override func viewDidLoad() {
super.viewDidLoad()
setupARSession()
}
private func setupARSession() {
sceneView.delegate = self
sceneView.showsStatistics = true
let scene = SCNScene()
sceneView.scene = scene
// Configure session for image tracking
guard ARImageTrackingConfiguration.isSupported else {
showAlert("AR not supported on this device")
return
}
let configuration = ARImageTrackingConfiguration()
// Load reference images for card recognition
if let referenceImages = ARReferenceImage.referenceImages(
inGroupNamed: "TradingCards",
bundle: Bundle.main
) {
configuration.trackingImages = referenceImages
configuration.maximumNumberOfTrackedImages = 3
}
sceneView.session.run(configuration)
}
}
extension ARCardViewController: ARSCNViewDelegate {
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
guard let imageAnchor = anchor as? ARImageAnchor else { return }
// Create 3D content for recognized card
let plane = SCNPlane(
width: imageAnchor.referenceImage.physicalSize.width,
height: imageAnchor.referenceImage.physicalSize.height
)
let cardInfo = getCardInfo(for: imageAnchor.referenceImage.name)
// Add floating info panel
let infoNode = createInfoPanel(for: cardInfo)
infoNode.position = SCNVector3(0, 0.1, 0)
let planeNode = SCNNode(geometry: plane)
planeNode.addChildNode(infoNode)
node.addChildNode(planeNode)
}
private func createInfoPanel(for card: CardInfo) -> SCNNode {
let text = SCNText(string: "\(card.name)\n\(card.year)", extrusionDepth: 1.0)
text.font = UIFont.systemFont(ofSize: 8)
text.firstMaterial?.diffuse.contents = UIColor.white
let textNode = SCNNode(geometry: text)
textNode.scale = SCNVector3(0.01, 0.01, 0.01)
return textNode
}
}
Android ARCore Implementation
import com.google.ar.core.*
import com.google.ar.sceneform.AnchorNode
import com.google.ar.sceneform.rendering.ViewRenderable
class ARCardActivity : AppCompatActivity() {
private lateinit var arFragment: ArFragment
private val anchors = mutableListOf<Anchor>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ar_card)
arFragment = supportFragmentManager.findFragmentById(R.id.ar_fragment) as ArFragment
arFragment.setOnTapArPlaneListener { hitResult, _, _ ->
addCardInfoToScene(hitResult)
}
}
private fun addCardInfoToScene(hitResult: HitResult) {
val anchor = hitResult.createAnchor()
anchors.add(anchor)
ViewRenderable.builder()
.setView(this, R.layout.card_info_ar)
.build()
.thenAccept { renderable ->
val anchorNode = AnchorNode(anchor)
val cardInfoNode = TransformableNode(arFragment.transformationSystem)
cardInfoNode.renderable = renderable
cardInfoNode.setParent(anchorNode)
arFragment.arSceneView.scene.addChild(anchorNode)
// Populate card information
populateCardInfo(renderable.view, getScannedCardInfo())
}
}
private fun populateCardInfo(view: View, cardInfo: CardInfo) {
view.findViewById<TextView>(R.id.card_name).text = cardInfo.name
view.findViewById<TextView>(R.id.card_year).text = cardInfo.year.toString()
view.findViewById<TextView>(R.id.estimated_value).text = "$${cardInfo.estimatedValue}"
Glide.with(this)
.load(cardInfo.imageUrl)
.into(view.findViewById<ImageView>(R.id.card_image))
}
}
Analytics and User Insights
Mobile Analytics Implementation
// React Native analytics
import analytics from '@react-native-firebase/analytics';
class MobileAnalytics {
static async trackCardView(card) {
await analytics().logEvent('card_viewed', {
card_id: card.id,
card_name: card.attributes.name,
card_year: card.attributes.year,
view_source: 'mobile_app'
});
}
static async trackSearch(searchTerm, resultCount) {
await analytics().logEvent('search_performed', {
search_term: searchTerm,
result_count: resultCount,
platform: 'mobile'
});
}
static async trackCollectionAction(action, cardId) {
await analytics().logEvent('collection_action', {
action: action, // 'add', 'remove', 'favorite'
card_id: cardId,
timestamp: Date.now()
});
}
static async trackAppUsage() {
const sessionStart = Date.now();
// Track when app goes to background
AppState.addEventListener('change', async (nextAppState) => {
if (nextAppState === 'background') {
const sessionDuration = Date.now() - sessionStart;
await analytics().logEvent('session_end', {
session_duration: sessionDuration,
platform: 'mobile'
});
}
});
}
}
User Behavior Tracking
// Flutter analytics
import 'package:firebase_analytics/firebase_analytics.dart';
class MobileAnalytics {
static final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
static Future<void> trackCardInteraction(String cardId, String action) async {
await _analytics.logEvent(
name: 'card_interaction',
parameters: {
'card_id': cardId,
'action': action,
'timestamp': DateTime.now().millisecondsSinceEpoch,
},
);
}
static Future<void> trackScreenView(String screenName) async {
await _analytics.logEvent(
name: 'screen_view',
parameters: {
'screen_name': screenName,
'platform': 'flutter',
},
);
}
static Future<void> trackFeatureUsage(String feature) async {
await _analytics.logEvent(
name: 'feature_used',
parameters: {
'feature_name': feature,
'user_type': 'mobile_user',
},
);
}
}
Testing Mobile Applications
iOS Unit Tests
import XCTest
@testable import TradingCardApp
class TradingCardAPITests: XCTestCase {
var apiClient: TradingCardAPIClient!
override func setUp() {
super.setUp()
apiClient = TradingCardAPIClient(
clientId: "test_client_id",
clientSecret: "test_client_secret"
)
}
func testFetchCards() async throws {
let expectation = XCTestExpectation(description: "Fetch cards")
do {
let response = try await apiClient.fetchCards(page: 1, limit: 10)
XCTAssertNotNil(response.data)
XCTAssertLessThanOrEqual(response.data.count, 10)
expectation.fulfill()
} catch {
XCTFail("Card fetch failed: \(error)")
}
wait(for: [expectation], timeout: 10.0)
}
func testOfflineStorage() async throws {
let testCard = Card(
id: "test-123",
attributes: CardAttributes(
name: "Test Card",
year: 2023,
imageUrlSmall: "https://example.com/image.jpg"
)
)
let offlineManager = OfflineDataManager()
offlineManager.cacheCards([testCard])
let cachedCards = offlineManager.getCachedCards()
XCTAssertEqual(cachedCards.count, 1)
XCTAssertEqual(cachedCards.first?.id, "test-123")
}
}
Android Integration Tests
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CardListActivityTest {
@get:Rule
val activityRule = ActivityTestRule(CardListActivity::class.java)
@Test
fun testCardListLoading() {
// Wait for cards to load
Thread.sleep(2000)
// Verify RecyclerView has items
onView(withId(R.id.cards_recycler_view))
.check(matches(hasMinimumChildCount(1)))
}
@Test
fun testCardItemClick() {
// Wait for cards to load
Thread.sleep(2000)
// Click first card item
onView(withId(R.id.cards_recycler_view))
.perform(RecyclerViewActions.actionOnItemAtPosition<CardViewHolder>(0, click()))
// Verify detail activity launches
intended(hasComponent(CardDetailActivity::class.java.name))
}
@Test
fun testPullToRefresh() {
// Perform pull to refresh
onView(withId(R.id.swipe_refresh_layout))
.perform(swipeDown())
// Verify refresh indicator appears
onView(withId(R.id.swipe_refresh_layout))
.check(matches(isDisplayed()))
}
}
Deployment and Distribution
iOS App Store Deployment
# Build for App Store
xcodebuild -workspace TradingCardApp.xcworkspace \
-scheme TradingCardApp \
-configuration Release \
-destination generic/platform=iOS \
clean archive \
-archivePath TradingCardApp.xcarchive
# Export for App Store
xcodebuild -exportArchive \
-archivePath TradingCardApp.xcarchive \
-exportPath . \
-exportOptionsPlist ExportOptions.plist
Android Play Store Deployment
# Build release APK
./gradlew assembleRelease
# Build App Bundle (recommended)
./gradlew bundleRelease
# Sign with keystore
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore release-key.keystore \
app-release-unsigned.apk \
release-key
React Native Distribution
{
"scripts": {
"build:ios": "react-native run-ios --configuration Release",
"build:android": "cd android && ./gradlew assembleRelease",
"bundle:ios": "react-native bundle --platform ios --dev false --entry-file index.js --bundle-output ios/main.jsbundle",
"bundle:android": "react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle"
}
}
Best Practices Summary
Performance Optimization
- Use image caching and lazy loading
- Implement pagination for large datasets
- Batch API requests when possible
- Cache frequently accessed data locally
- Use background sync for data updates
User Experience
- Provide offline functionality for core features
- Implement pull-to-refresh and infinite scrolling
- Use platform-specific UI patterns
- Optimize for different screen sizes and orientations
- Provide visual feedback for loading states
Security
- Store sensitive data in secure storage (Keychain/EncryptedSharedPreferences)
- Implement proper token refresh mechanisms
- Validate all user inputs
- Use HTTPS for all API communications
- Implement certificate pinning for enhanced security
Testing
- Write unit tests for business logic
- Create integration tests for API interactions
- Test offline functionality thoroughly
- Verify performance on low-end devices
- Test different network conditions
This comprehensive guide provides the foundation for building engaging mobile trading card applications that leverage the full power of the Trading Card API across all major mobile platforms.