southgames_flutter 0.7.3 copy "southgames_flutter: ^0.7.3" to clipboard
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,
  });
}
0
likes
110
points
165
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Homepage
Repository (GitHub)
View/report issues

Topics

#gamification #loyalty #games #promotions #notifications

License

MIT (license)

Dependencies

firebase_core, firebase_messaging, flutter, geolocator, http, package_info_plus, shared_preferences, webview_flutter

More

Packages that depend on southgames_flutter