southgames_flutter 0.7.3
southgames_flutter: ^0.7.3 copied to clipboard
Flutter SDK for SouthGames — integrate gamification and loyalty features (spin wheel, scratch cards, trivia, slot machine, promo codes, in-app notifications) into your Flutter app with a single package.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:southgames_flutter/southgames_flutter.dart';
// import 'firebase_options.dart'; // Generated by `flutterfire configure`
/// Background message handler — must be a top-level function.
@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
await SouthGamesSDK.handleBackgroundMessage(message);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 1. Initialize Firebase (required before SouthGamesSDK.init)
await Firebase.initializeApp(
// options: DefaultFirebaseOptions.currentPlatform, // Uncomment after running `flutterfire configure`
);
// 2. Register background message handler
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
// 3. Initialize SouthGames — push, in-app notifications, and
// device registration are all set up automatically.
await SouthGamesSDK.init(
apiKey: 'sg_live_xxxxxxxxxxxxxxxx',
orgId: 'your-org-slug',
pushConfig: PushConfig(
requestPermissionOnInit: true,
showForegroundNotifications: true,
onNotificationTap: (message) {
debugPrint('Notification tapped: ${message.data}');
},
onForegroundMessage: (message) {
debugPrint('Foreground message: ${message.notification?.title}');
},
),
onCtaTap: (action) {
debugPrint('CTA tapped: $action');
},
);
runApp(const MyApp());
}
/// Example app demonstrating the SouthGames Flutter SDK.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SouthGames Demo',
theme: ThemeData(
colorSchemeSeed: Colors.cyan,
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
/// Home screen with gamification features.
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
static const _externalUserId = 'demo_user_123';
String? _clientId;
List<Campaign> _campaigns = [];
final List<_GeneratedCode> _codes = [];
String _status = 'Identificando cliente...';
@override
void initState() {
super.initState();
_identify();
}
Future<void> _identify() async {
try {
final sdk = SouthGamesSDK.instance;
final res = await sdk.identify(
externalId: _externalUserId,
email: 'demo@example.com',
customParams: {'plan': 'free'},
);
setState(() {
_clientId = res.clientId;
_status = res.created
? 'Cliente registrado: ${res.clientId}'
: 'Cliente actualizado: ${res.clientId}';
});
await sdk.heartbeat(clientId: res.clientId);
} on SouthGamesException catch (e) {
setState(() => _status = 'Error: ${e.message}');
}
}
Future<void> _loadCampaigns() async {
try {
final campaigns = await SouthGamesSDK.instance.getCampaigns();
setState(() {
_campaigns = campaigns;
_status = '${campaigns.length} campaña(s) encontrada(s)';
});
} on SouthGamesException catch (e) {
setState(() => _status = 'Error: ${e.message}');
}
}
Future<void> _play(Campaign campaign) async {
try {
final result = await SouthGamesSDK.instance.play(
campaignId: campaign.id,
externalUserId: _externalUserId,
);
setState(() {
if (result.won) {
_status = '¡Ganaste! Código: ${result.prizeCode ?? "sin código"}';
if (result.prizeCode != null) {
_codes.insert(
0,
_GeneratedCode(
code: result.prizeCode!,
campaignName: campaign.name,
discountType: null,
discountValue: null,
maxDiscountAmount: null,
source: 'game',
idempotent: false,
),
);
}
} else {
_status = 'No ganaste. Resultado: ${result.result}';
}
});
} on SouthGamesException catch (e) {
setState(() => _status = 'Error: ${e.message}');
}
}
Future<void> _createCode(Campaign campaign) async {
try {
final res = await SouthGamesSDK.instance.createPromoCode(
campaignId: campaign.id,
externalUserId: _externalUserId,
);
setState(() {
_codes.insert(
0,
_GeneratedCode(
code: res.code,
campaignName: campaign.name,
discountType: res.discountType,
discountValue: res.discountValue,
maxDiscountAmount: res.maxDiscountAmount,
source: 'sdk',
idempotent: res.idempotent,
),
);
_status = res.idempotent
? 'Código existente reutilizado: ${res.code}'
: 'Código generado: ${res.code}';
});
} on SouthGamesException catch (e) {
setState(() => _status = 'Error creando código: ${e.message}');
}
}
Future<void> _redeem(_GeneratedCode c) async {
try {
final res = await SouthGamesSDK.instance.redeem(
code: c.code,
externalUserId: _externalUserId,
);
setState(() {
c.redeemed = true;
c.remainingUses = res.remainingUses;
final discountLabel = res.discountType == 'percentage'
? '${res.discountValue}%'
: '\$${res.discountValue}';
_status = '✓ Canjeado ${c.code} — ${discountLabel} off'
'${res.maxDiscountAmount != null ? " (tope \$${res.maxDiscountAmount})" : ""}';
});
} on SouthGamesException catch (e) {
setState(() => _status = 'Error canjeando: ${e.message}');
}
}
Future<void> _trackEvent() async {
try {
final result = await SouthGamesSDK.instance.trackEvent(
eventName: 'button_clicked',
externalId: _externalUserId,
properties: {'screen': 'home'},
);
setState(() {
_status = 'Evento trackeado: ${result.eventName}';
if (result.triggeredNotifications.isNotEmpty) {
_status +=
' (${result.triggeredNotifications.length} notificación(es) triggered)';
}
});
} on SouthGamesException catch (e) {
setState(() => _status = 'Error: ${e.message}');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('SouthGames Demo'),
actions: [
IconButton(
onPressed: _loadCampaigns,
icon: const Icon(Icons.refresh),
tooltip: 'Cargar campañas',
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_status, style: Theme.of(context).textTheme.bodyLarge),
if (_clientId != null) ...[
const SizedBox(height: 4),
Text(
'externalUserId: $_externalUserId',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _identify,
icon: const Icon(Icons.person, size: 18),
label: const Text('Identificar'),
),
FilledButton.tonalIcon(
onPressed: _loadCampaigns,
icon: const Icon(Icons.games, size: 18),
label: const Text('Campañas'),
),
FilledButton.tonalIcon(
onPressed: _trackEvent,
icon: const Icon(Icons.track_changes, size: 18),
label: const Text('Evento'),
),
],
),
const SizedBox(height: 24),
if (_campaigns.isNotEmpty) ...[
Text(
'Campañas',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
for (final c in _campaigns) _CampaignCard(
campaign: c,
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CampaignDetailScreen(
campaign: c,
onPlay: () => _play(c),
onCreateCode: () => _createCode(c),
),
),
);
// The detail screen already mutated _codes / _status via the
// callbacks. setState() to refresh this ListView.
if (mounted) setState(() {});
},
),
const SizedBox(height: 24),
],
if (_codes.isNotEmpty) ...[
Text(
'Códigos (${_codes.length})',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
for (final code in _codes) _CodeCard(
code: code,
onRedeem: () => _redeem(code),
),
],
],
),
);
}
}
class _CampaignCard extends StatelessWidget {
final Campaign campaign;
final VoidCallback onTap;
const _CampaignCard({required this.campaign, required this.onTap});
@override
Widget build(BuildContext context) {
final gameType = campaign.gameType;
final isGaming = gameType != null && gameType.isNotEmpty;
final imageUrl = campaign.imageUrl;
return Card(
margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Row(
children: [
SizedBox(
width: 72,
height: 72,
child: imageUrl != null && imageUrl.isNotEmpty
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _CampaignPlaceholder(isGaming: isGaming),
)
: _CampaignPlaceholder(isGaming: isGaming),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(campaign.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 2),
Text(
isGaming ? 'gaming · $gameType' : 'traditional',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
),
const Padding(
padding: EdgeInsets.only(right: 12),
child: Icon(Icons.chevron_right),
),
],
),
),
);
}
}
class _CampaignPlaceholder extends StatelessWidget {
final bool isGaming;
const _CampaignPlaceholder({required this.isGaming});
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
alignment: Alignment.center,
child: Icon(
isGaming ? Icons.sports_esports : Icons.local_offer,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}
/// Detail view for a single campaign. Shows the hero image, title,
/// description, date range, and one primary action button:
/// - gaming campaigns → "Jugar" (calls play())
/// - traditional campaigns → "Crear código" (calls createPromoCode())
class CampaignDetailScreen extends StatefulWidget {
final Campaign campaign;
final Future<void> Function() onPlay;
final Future<void> Function() onCreateCode;
const CampaignDetailScreen({
super.key,
required this.campaign,
required this.onPlay,
required this.onCreateCode,
});
@override
State<CampaignDetailScreen> createState() => _CampaignDetailScreenState();
}
class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
bool _busy = false;
Future<void> _runAction() async {
setState(() => _busy = true);
try {
final isGaming =
widget.campaign.gameType != null && widget.campaign.gameType!.isNotEmpty;
if (isGaming) {
await widget.onPlay();
} else {
await widget.onCreateCode();
}
if (!mounted) return;
Navigator.of(context).pop();
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final c = widget.campaign;
final gameType = c.gameType;
final isGaming = gameType != null && gameType.isNotEmpty;
final imageUrl = c.imageUrl;
return Scaffold(
appBar: AppBar(title: const Text('Campaña')),
body: ListView(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: imageUrl != null && imageUrl.isNotEmpty
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _CampaignPlaceholder(isGaming: isGaming),
)
: _CampaignPlaceholder(isGaming: isGaming),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Chip(
label: Text(isGaming ? 'Juego' : 'Tradicional'),
visualDensity: VisualDensity.compact,
),
if (isGaming) ...[
const SizedBox(width: 8),
Chip(
label: Text(gameType),
visualDensity: VisualDensity.compact,
),
],
],
),
const SizedBox(height: 12),
Text(c.name, style: Theme.of(context).textTheme.headlineSmall),
if (c.description.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
c.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
const SizedBox(height: 20),
_DetailDateRow(
icon: Icons.event_available,
label: 'Inicia',
value: _formatDate(c.startsAt),
),
const SizedBox(height: 8),
_DetailDateRow(
icon: Icons.event_busy,
label: 'Finaliza',
value: _formatDate(c.endsAt),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _busy ? null : _runAction,
icon: _busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: Icon(isGaming
? Icons.play_arrow
: Icons.confirmation_number_outlined),
label: Text(isGaming ? 'Jugar' : 'Crear código'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
),
],
),
);
}
}
class _DetailDateRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _DetailDateRow({
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
return Row(
children: [
Icon(icon, size: 18, color: onSurfaceVariant),
const SizedBox(width: 8),
Text(
'$label: ',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: onSurfaceVariant,
),
),
Text(value, style: Theme.of(context).textTheme.bodyMedium),
],
);
}
}
const _monthsEs = [
'ene', 'feb', 'mar', 'abr', 'may', 'jun',
'jul', 'ago', 'sep', 'oct', 'nov', 'dic',
];
String _formatDate(String? iso) {
if (iso == null || iso.isEmpty) return 'sin fecha';
final parsed = DateTime.tryParse(iso);
if (parsed == null) return iso;
final d = parsed.toLocal();
final hh = d.hour.toString().padLeft(2, '0');
final mm = d.minute.toString().padLeft(2, '0');
return '${d.day} ${_monthsEs[d.month - 1]} ${d.year}, $hh:$mm';
}
class _CodeCard extends StatelessWidget {
final _GeneratedCode code;
final VoidCallback onRedeem;
const _CodeCard({required this.code, required this.onRedeem});
@override
Widget build(BuildContext context) {
final discountLabel = code.discountType == 'percentage'
? '${code.discountValue}%'
: code.discountValue != null
? '\$${code.discountValue}'
: null;
final subtitleParts = <String>[
code.campaignName,
if (discountLabel != null) '$discountLabel off',
if (code.maxDiscountAmount != null) 'tope \$${code.maxDiscountAmount}',
if (code.idempotent) 'reutilizado',
];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SelectableText(
code.code,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
decoration: code.redeemed
? TextDecoration.lineThrough
: null,
),
),
const SizedBox(width: 8),
if (code.redeemed)
Chip(
label: Text(
code.remainingUses == 0
? 'canjeado'
: 'canjeado (${code.remainingUses} restantes)',
style: const TextStyle(fontSize: 11),
),
visualDensity: VisualDensity.compact,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
],
),
const SizedBox(height: 2),
Text(
subtitleParts.join(' · '),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: code.redeemed ? null : onRedeem,
icon: const Icon(Icons.local_fire_department, size: 18),
label: const Text('Canjear'),
),
],
),
),
);
}
}
class _GeneratedCode {
final String code;
final String campaignName;
final String? discountType;
final num? discountValue;
final num? maxDiscountAmount;
/// `'sdk'` when created via createPromoCode, `'game'` when won in a play.
final String source;
final bool idempotent;
bool redeemed = false;
int remainingUses = 0;
_GeneratedCode({
required this.code,
required this.campaignName,
required this.discountType,
required this.discountValue,
required this.maxDiscountAmount,
required this.source,
required this.idempotent,
});
}