Skip to main content

cafebazaar_billing 1.2.0 copy "cafebazaar_billing: ^1.2.0" to clipboard
cafebazaar_billing: ^1.2.0 copied to clipboard

PlatformAndroid

cafebazaar_billing is a Flutter plugin that acts as a gate to Cafe Bazaar's native Android In-App Billing, so Flutter apps can sell products and subscriptions on the Cafe Bazaar store.

example/lib/main.dart

import 'package:cafebazaar_billing/cafebazaar_billing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const BillingDemoApp());
}

const Color _brandColor = Color(0xFF7B2FF2);
const Color _brandDark = Color(0xFF2D0B59);

class BillingDemoApp extends StatelessWidget {
  const BillingDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Cafe Bazaar Billing Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: _brandColor,
          brightness: Brightness.light,
        ),
        inputDecorationTheme: const InputDecorationTheme(
          border: OutlineInputBorder(
            borderRadius: BorderRadius.all(Radius.circular(14)),
          ),
          contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
        ),
      ),
      home: const DemoHomePage(),
    );
  }
}

enum _ConnStatus { disconnected, connecting, connected, failed }

enum _LogLevel { info, success, error }

class _LogEntry {
  _LogEntry(this.time, this.message, this.level);

  final DateTime time;
  final String message;
  final _LogLevel level;
}

class _Product {
  const _Product({
    required this.id,
    required this.title,
    required this.description,
    required this.icon,
    this.isSubscription = false,
  });

  final String id;
  final String title;
  final String description;
  final IconData icon;
  final bool isSubscription;
}

final List<_Product> _kCatalog = [
  _Product(
    id: 'gem_pack_small',
    title: 'Bag of Gems',
    description: '100 gems to boost your journey',
    icon: Icons.diamond_outlined,
  ),
  _Product(
    id: 'gem_pack_large',
    title: 'Chest of Gems',
    description: '500 gems — best value bundle',
    icon: Icons.inventory_2_outlined,
  ),
  _Product(
    id: 'remove_ads',
    title: 'Remove Ads',
    description: 'Enjoy the app ad-free forever',
    icon: Icons.block_outlined,
  ),
  _Product(
    id: 'premium_monthly',
    title: 'Premium Monthly',
    description: 'All premium features, billed monthly',
    icon: Icons.workspace_premium_outlined,
    isSubscription: true,
  ),
  _Product(
    id: 'premium_yearly',
    title: 'Premium Yearly',
    description: 'Save more with the annual plan',
    icon: Icons.stars_outlined,
    isSubscription: true,
  ),
];

class DemoHomePage extends StatefulWidget {
  const DemoHomePage({super.key});

  @override
  State<DemoHomePage> createState() => _DemoHomePageState();
}

class _DemoHomePageState extends State<DemoHomePage> {
  final TextEditingController _rsaKeyController = TextEditingController();
  final ScrollController _logScrollController = ScrollController();

  _ConnStatus connection = _ConnStatus.disconnected;
  bool busy = false;

  Map<String, SkuDetails> skuMap = {};
  List<PurchaseInfo> purchases = [];
  List<PurchaseInfo> subscriptions = [];
  final List<_LogEntry> logs = [];

  bool get isConnected => connection == _ConnStatus.connected;

  @override
  void initState() {
    super.initState();
    _log('Welcome! Connect to Cafe Bazaar to begin.', _LogLevel.info);
  }

  @override
  void dispose() {
    if (isConnected) {
      CafeBazaarBilling.disconnect();
    }
    _rsaKeyController.dispose();
    _logScrollController.dispose();
    super.dispose();
  }

  void _log(String message, _LogLevel level) {
    setState(() {
      logs.add(_LogEntry(DateTime.now(), message, level));
      if (logs.length > 200) {
        logs.removeAt(0);
      }
    });
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_logScrollController.hasClients) {
        _logScrollController.animateTo(
          _logScrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 250),
          curve: Curves.easeOut,
        );
      }
    });
  }

  void _toast(String message, {bool error = false}) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        behavior: SnackBarBehavior.floating,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
        backgroundColor: error ? Colors.red.shade700 : _brandDark,
        content: Text(message),
      ),
    );
  }

  Future<void> _guarded(Future<void> Function() action) async {
    if (busy) return;
    setState(() => busy = true);
    try {
      await action();
    } on PlatformException catch (e) {
      _log('${e.code}: ${e.message ?? 'unknown error'}', _LogLevel.error);
      _toast(_friendlyError(e.code), error: true);
    } catch (e) {
      _log('Unexpected error: $e', _LogLevel.error);
      _toast('Unexpected error', error: true);
    } finally {
      if (mounted) setState(() => busy = false);
    }
  }

  String _friendlyError(String code) {
    switch (code) {
      case 'PAYMENT_CONNECTION_IS_NOT_CONNECTED':
        return 'Not connected. Connect to Bazaar first.';
      case 'PURCHASE_CANCELLED':
        return 'Purchase cancelled.';
      case 'PURCHASE_FAILED':
        return 'Purchase failed. Please try again.';
      case 'FAILED_TO_BEGIN_FLOW':
        return 'Could not open Bazaar. Is it installed?';
      case 'CONSUME_FAILED':
        return 'Consume failed. Refresh inventory and retry.';
      default:
        return 'Something went wrong ($code)';
    }
  }

  Future<void> _connect() async {
    final key = _rsaKeyController.text.trim();
    setState(() => connection = _ConnStatus.connecting);
    _log(
      'Connecting${key.isEmpty ? ' without RSA key' : ''}...',
      _LogLevel.info,
    );
    try {
      await CafeBazaarBilling.connect(
        key.isEmpty ? null : key,
        onSucceed: () {
          setState(() => connection = _ConnStatus.connected);
          _log('Connected to Cafe Bazaar', _LogLevel.success);
          _refreshStoreData();
        },
        onFailed: () {
          setState(() => connection = _ConnStatus.failed);
          _log('Connection failed', _LogLevel.error);
        },
        onDisconnected: () {
          setState(() => connection = _ConnStatus.disconnected);
          _log('Disconnected from Bazaar', _LogLevel.error);
          _toast('Connection lost', error: true);
        },
      );
    } on PlatformException catch (e) {
      setState(() => connection = _ConnStatus.failed);
      _log('Connect error [${e.code}] ${e.message}', _LogLevel.error);
    }
  }

  Future<void> _disconnect() => _guarded(() async {
    await CafeBazaarBilling.disconnect();
    setState(() {
      connection = _ConnStatus.disconnected;
      skuMap = {};
      purchases = [];
      subscriptions = [];
    });
    _log('Disconnected', _LogLevel.info);
  });

  Future<void> _refreshStoreData() => _guarded(() async {
    final results = await Future.wait([
      CafeBazaarBilling.getInAppSkuDetails(
        _kCatalog.where((p) => !p.isSubscription).map((p) => p.id).toList(),
      ),
      CafeBazaarBilling.getSubscriptionSkuDetails(
        _kCatalog.where((p) => p.isSubscription).map((p) => p.id).toList(),
      ),
      CafeBazaarBilling.getAllPurchasedProducts(),
      CafeBazaarBilling.getAllSubscribedProducts(),
    ]);
    setState(() {
      skuMap = {
        for (final s in [
          ...results[0] as List<SkuDetails>,
          ...results[1] as List<SkuDetails>,
        ])
          s.sku: s,
      };
      purchases = results[2] as List<PurchaseInfo>;
      subscriptions = results[3] as List<PurchaseInfo>;
    });
    _log(
      'Inventory: ${purchases.length} purchased, ${subscriptions.length} subscribed',
      _LogLevel.success,
    );
  });

  Future<void> _buy(_Product product) => _guarded(() async {
    _log(
      '${product.isSubscription ? 'Subscribing' : 'Buying'} "${product.title}"...',
      _LogLevel.info,
    );
    final info = product.isSubscription
        ? await CafeBazaarBilling.subscribe(product.id)
        : await CafeBazaarBilling.purchase(product.id);
    _log(
      'Success: ${info.productId} (state: ${info.purchaseState.name})',
      _LogLevel.success,
    );
    _toast('Thank you! "${product.title}" is now yours.');
    await _refreshStoreData();
  });

  Future<void> _consume(PurchaseInfo info) => _guarded(() async {
    _log('Consuming ${info.productId}...', _LogLevel.info);
    final ok = await CafeBazaarBilling.consume(info.purchaseToken);
    _log(
      ok ? 'Consumed ${info.productId}' : 'Consume returned false',
      ok ? _LogLevel.success : _LogLevel.error,
    );
    await _refreshStoreData();
  });

  Future<void> _checkTrial() => _guarded(() async {
    final trial = await CafeBazaarBilling.checkTrialSubscription();
    final available = trial['isAvailable'] == true;
    final days = trial['trialPeriodDays'];
    _log(
      'Trial available: $available (${days ?? '?'} days)',
      available ? _LogLevel.success : _LogLevel.info,
    );
    if (!mounted) return;
    showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Row(
          children: [
            Icon(Icons.card_giftcard),
            SizedBox(width: 8),
            Text('Free Trial'),
          ],
        ),
        content: Text(
          available
              ? 'You are eligible for a free trial.\nTrial length: $days day(s).'
              : 'No free trial available for this account.',
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('OK'),
          ),
        ],
      ),
    );
  });

  Future<void> _showVersion() => _guarded(() async {
    final v = await CafeBazaarBilling.getVersion();
    _log('Native billing library version: $v', _LogLevel.info);
    _toast('Native billing version: $v');
  });

  String _priceOf(_Product p) {
    final details = skuMap[p.id];
    return details?.price ??
        (p.isSubscription ? 'subscription' : 'tap to load prices');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [_brandDark, Colors.white],
            stops: [0.0, 0.45],
          ),
        ),
        child: SafeArea(
          bottom: false,
          child: Column(
            children: [
              _Header(
                connection: connection,
                versionFuture: CafeBazaarBilling.getVersion(),
              ),
              Expanded(
                child: RefreshIndicator(
                  onRefresh: isConnected ? _refreshStoreData : () async {},
                  child: ListView(
                    padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
                    children: [
                      _ConnectionCard(
                        controller: _rsaKeyController,
                        connection: connection,
                        busy: busy,
                        onConnect: _connect,
                        onDisconnect: _disconnect,
                      ),
                      const SizedBox(height: 16),
                      Row(
                        children: [
                          Expanded(
                            child: _QuickAction(
                              icon: Icons.card_giftcard_outlined,
                              label: 'Check Trial',
                              enabled: isConnected && !busy,
                              onTap: _checkTrial,
                            ),
                          ),
                          const SizedBox(width: 10),
                          Expanded(
                            child: _QuickAction(
                              icon: Icons.refresh_rounded,
                              label: 'Refresh',
                              enabled: isConnected && !busy,
                              onTap: _refreshStoreData,
                            ),
                          ),
                          const SizedBox(width: 10),
                          Expanded(
                            child: _QuickAction(
                              icon: Icons.info_outline_rounded,
                              label: 'Version',
                              enabled: true,
                              onTap: _showVersion,
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 24),
                      _SectionTitle('Store', Icons.storefront_rounded),
                      ..._kCatalog.map(
                        (p) => _ProductTile(
                          product: p,
                          price: _priceOf(p),
                          enabled: isConnected && !busy,
                          onBuy: () => _buy(p),
                        ),
                      ),
                      const SizedBox(height: 24),
                      _SectionTitle('My Purchases', Icons.receipt_long_rounded),
                      if (purchases.isEmpty)
                        const _EmptyHint(text: 'No purchased items yet.')
                      else
                        ...purchases.map(
                          (info) => _InventoryTile(
                            info: info,
                            trailingLabel: 'Consume',
                            trailingIcon: Icons.local_convenience_store_rounded,
                            enabled: isConnected && !busy,
                            onAction: () => _consume(info),
                          ),
                        ),
                      const SizedBox(height: 24),
                      _SectionTitle(
                        'My Subscriptions',
                        Icons.workspace_premium_rounded,
                      ),
                      if (subscriptions.isEmpty)
                        const _EmptyHint(text: 'No active subscriptions yet.')
                      else
                        ...subscriptions.map(
                          (info) => _InventoryTile(
                            info: info,
                            showStateChip: true,
                            enabled: false,
                            onAction: () {},
                          ),
                        ),
                      const SizedBox(height: 24),
                      _SectionTitle('Activity Log', Icons.terminal_rounded),
                      _LogConsole(
                        entries: logs,
                        scrollController: _logScrollController,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Header extends StatelessWidget {
  const _Header({required this.connection, required this.versionFuture});

  final _ConnStatus connection;
  final Future<String> versionFuture;

  @override
  Widget build(BuildContext context) {
    const labels = {
      _ConnStatus.disconnected: 'Disconnected',
      _ConnStatus.connecting: 'Connecting...',
      _ConnStatus.connected: 'Connected',
      _ConnStatus.failed: 'Connection Failed',
    };
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
      child: Row(
        children: [
          Container(
            width: 52,
            height: 52,
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(16),
              boxShadow: [
                BoxShadow(
                  color: Colors.black.withValues(alpha: 0.25),
                  blurRadius: 12,
                ),
              ],
            ),
            child: const Icon(
              Icons.shopping_bag_rounded,
              color: _brandColor,
              size: 30,
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text(
                  'cafebazaar_billing',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 20,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                FutureBuilder<String>(
                  future: versionFuture,
                  builder: (context, snapshot) {
                    final v = snapshot.data;
                    return Text(
                      'In-App Billing Demo${v != null ? '  •  native $v' : ''}',
                      style: TextStyle(
                        color: Colors.white.withValues(alpha: 0.75),
                        fontSize: 13,
                      ),
                    );
                  },
                ),
              ],
            ),
          ),
          _StatusPill(
            label: labels[connection]!,
            connected: connection == _ConnStatus.connected,
            connecting: connection == _ConnStatus.connecting,
          ),
        ],
      ),
    );
  }
}

class _StatusPill extends StatefulWidget {
  const _StatusPill({
    required this.label,
    required this.connected,
    required this.connecting,
  });

  final String label;
  final bool connected;
  final bool connecting;

  @override
  State<_StatusPill> createState() => _StatusPillState();
}

class _StatusPillState extends State<_StatusPill>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 1),
      value: 1,
    );
    if (widget.connecting) {
      _controller.repeat(reverse: true);
    }
  }

  @override
  void didUpdateWidget(covariant _StatusPill oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.connecting && !_controller.isAnimating) {
      _controller.repeat(reverse: true);
    } else if (!widget.connecting && _controller.isAnimating) {
      _controller.stop();
      _controller.value = 1;
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final color = widget.connected
        ? const Color(0xFF4CE08B)
        : widget.connecting
        ? Colors.amberAccent
        : Colors.redAccent.shade200;
    return FadeTransition(
      opacity: widget.connecting
          ? _controller.drive(Tween(begin: 0.55, end: 1.0))
          : const AlwaysStoppedAnimation(1),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
        decoration: BoxDecoration(
          color: Colors.black.withValues(alpha: 0.28),
          borderRadius: BorderRadius.circular(999),
          border: Border.all(color: color.withValues(alpha: 0.6)),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            _PulsingDot(
              color: color,
              animate: widget.connected || widget.connecting,
            ),
            const SizedBox(width: 8),
            Text(
              widget.label,
              style: const TextStyle(
                color: Colors.white,
                fontSize: 12.5,
                fontWeight: FontWeight.w600,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _PulsingDot extends StatefulWidget {
  const _PulsingDot({required this.color, required this.animate});

  final Color color;
  final bool animate;

  @override
  State<_PulsingDot> createState() => _PulsingDotState();
}

class _PulsingDotState extends State<_PulsingDot>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 900),
      value: 1,
    );
    if (widget.animate) {
      _controller.repeat(reverse: true);
    }
  }

  @override
  void didUpdateWidget(covariant _PulsingDot oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.animate && !_controller.isAnimating) {
      _controller.repeat(reverse: true);
    } else if (!widget.animate && _controller.isAnimating) {
      _controller.stop();
      _controller.value = 1;
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _controller.drive(Tween(begin: 0.75, end: 1.15)),
      child: Container(
        width: 9,
        height: 9,
        decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
      ),
    );
  }
}

class _ConnectionCard extends StatelessWidget {
  const _ConnectionCard({
    required this.controller,
    required this.connection,
    required this.busy,
    required this.onConnect,
    required this.onDisconnect,
  });

  final TextEditingController controller;
  final _ConnStatus connection;
  final bool busy;
  final VoidCallback onConnect;
  final VoidCallback onDisconnect;

  @override
  Widget build(BuildContext context) {
    final connected = connection == _ConnStatus.connected;
    return Card(
      elevation: 3,
      shadowColor: Colors.black26,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
      child: Padding(
        padding: const EdgeInsets.all(18),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(
                  Icons.vpn_key_rounded,
                  color: Theme.of(context).colorScheme.primary,
                ),
                const SizedBox(width: 8),
                const Text(
                  'RSA Public Key',
                  style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
                ),
                const Spacer(),
                Text(
                  'optional',
                  style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
                ),
              ],
            ),
            const SizedBox(height: 6),
            Text(
              'Leave empty to disable local signature validation.',
              style: TextStyle(color: Colors.grey.shade600, fontSize: 12.5),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: controller,
              enabled: !connected,
              maxLines: 2,
              minLines: 1,
              decoration: InputDecoration(
                hintText: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...',
                suffixIcon: connected
                    ? const Icon(Icons.lock_outline, size: 18)
                    : null,
              ),
            ),
            const SizedBox(height: 14),
            Row(
              children: [
                Expanded(
                  child: FilledButton.icon(
                    onPressed:
                        connected ||
                            busy ||
                            connection == _ConnStatus.connecting
                        ? null
                        : onConnect,
                    icon: connection == _ConnStatus.connecting
                        ? const SizedBox(
                            width: 16,
                            height: 16,
                            child: CircularProgressIndicator(strokeWidth: 2),
                          )
                        : const Icon(Icons.link_rounded),
                    label: Text(connected ? 'Connected' : 'Connect'),
                  ),
                ),
                const SizedBox(width: 10),
                Expanded(
                  child: OutlinedButton.icon(
                    onPressed: connected && !busy ? onDisconnect : null,
                    icon: const Icon(Icons.link_off_rounded),
                    label: const Text('Disconnect'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class _QuickAction extends StatelessWidget {
  const _QuickAction({
    required this.icon,
    required this.label,
    required this.enabled,
    required this.onTap,
  });

  final IconData icon;
  final String label;
  final bool enabled;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return Opacity(
      opacity: enabled ? 1 : 0.45,
      child: Material(
        color: Theme.of(
          context,
        ).colorScheme.primaryContainer.withValues(alpha: 0.55),
        borderRadius: BorderRadius.circular(16),
        child: InkWell(
          borderRadius: BorderRadius.circular(16),
          onTap: enabled ? onTap : null,
          child: Padding(
            padding: const EdgeInsets.symmetric(vertical: 14),
            child: Column(
              children: [
                Icon(
                  icon,
                  size: 22,
                  color: Theme.of(context).colorScheme.primary,
                ),
                const SizedBox(height: 6),
                Text(
                  label,
                  style: const TextStyle(
                    fontSize: 12,
                    fontWeight: FontWeight.w600,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _SectionTitle extends StatelessWidget {
  const _SectionTitle(this.text, this.icon);

  final String text;
  final IconData icon;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 10, left: 4),
      child: Row(
        children: [
          Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
          const SizedBox(width: 6),
          Text(
            text,
            style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
          ),
        ],
      ),
    );
  }
}

class _ProductTile extends StatelessWidget {
  const _ProductTile({
    required this.product,
    required this.price,
    required this.enabled,
    required this.onBuy,
  });

  final _Product product;
  final String price;
  final bool enabled;
  final VoidCallback onBuy;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 10),
      elevation: 1.5,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
        child: Row(
          children: [
            Container(
              width: 46,
              height: 46,
              decoration: BoxDecoration(
                color:
                    (product.isSubscription
                            ? const Color(0xFFFFB300)
                            : _brandColor)
                        .withValues(alpha: 0.14),
                borderRadius: BorderRadius.circular(14),
              ),
              child: Icon(
                product.icon,
                color: product.isSubscription
                    ? const Color(0xFFB27A00)
                    : _brandColor,
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    children: [
                      Flexible(
                        child: Text(
                          product.title,
                          overflow: TextOverflow.ellipsis,
                          style: const TextStyle(fontWeight: FontWeight.w700),
                        ),
                      ),
                      const SizedBox(width: 6),
                      if (product.isSubscription)
                        Container(
                          padding: const EdgeInsets.symmetric(
                            horizontal: 7,
                            vertical: 2,
                          ),
                          decoration: BoxDecoration(
                            color: const Color(
                              0xFFFFB300,
                            ).withValues(alpha: 0.2),
                            borderRadius: BorderRadius.circular(999),
                          ),
                          child: const Text(
                            'SUB',
                            style: TextStyle(
                              fontSize: 9.5,
                              fontWeight: FontWeight.bold,
                              color: Color(0xFF9A6B00),
                            ),
                          ),
                        ),
                    ],
                  ),
                  const SizedBox(height: 2),
                  Text(
                    product.description,
                    style: TextStyle(
                      fontSize: 12.5,
                      color: Colors.grey.shade600,
                    ),
                    overflow: TextOverflow.ellipsis,
                  ),
                  const SizedBox(height: 4),
                  Text(
                    price,
                    style: TextStyle(
                      fontSize: 13,
                      fontWeight: FontWeight.w600,
                      color: Theme.of(context).colorScheme.primary,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 8),
            FilledButton.tonal(
              onPressed: enabled ? onBuy : null,
              style: FilledButton.styleFrom(
                backgroundColor: Theme.of(context).colorScheme.primary,
                foregroundColor: Colors.white,
              ),
              child: Text(product.isSubscription ? 'Subscribe' : 'Buy'),
            ),
          ],
        ),
      ),
    );
  }
}

class _InventoryTile extends StatelessWidget {
  const _InventoryTile({
    required this.info,
    required this.enabled,
    required this.onAction,
    this.trailingLabel,
    this.trailingIcon,
    this.showStateChip = false,
  });

  final PurchaseInfo info;
  final bool enabled;
  final VoidCallback onAction;
  final String? trailingLabel;
  final IconData? trailingIcon;
  final bool showStateChip;

  @override
  Widget build(BuildContext context) {
    final refunded = info.purchaseState == PurchaseState.REFUNDED;
    return Card(
      margin: const EdgeInsets.only(bottom: 10),
      elevation: 1,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: (refunded ? Colors.red : Colors.green).withValues(
            alpha: 0.15,
          ),
          child: Icon(
            refunded ? Icons.money_off_rounded : Icons.check_circle_rounded,
            color: refunded ? Colors.red : Colors.green,
            size: 22,
          ),
        ),
        title: Text(
          info.productId,
          style: const TextStyle(fontWeight: FontWeight.w600),
        ),
        subtitle: Text(
          '${info.purchaseState.name} • ${DateTime.fromMillisecondsSinceEpoch(info.purchaseTime)}\ntoken: ${info.purchaseToken.length > 26 ? '${info.purchaseToken.substring(0, 26)}…' : info.purchaseToken}',
          style: TextStyle(
            fontSize: 11.5,
            color: Colors.grey.shade600,
            height: 1.35,
          ),
        ),
        isThreeLine: true,
        trailing: showStateChip
            ? Chip(
                avatar: const Icon(Icons.workspace_premium_rounded, size: 15),
                label: const Text('Active', style: TextStyle(fontSize: 11)),
                visualDensity: VisualDensity.compact,
              )
            : TextButton.icon(
                onPressed: enabled ? onAction : null,
                icon: Icon(trailingIcon ?? Icons.autorenew_rounded, size: 16),
                label: Text(trailingLabel ?? 'Refresh'),
              ),
      ),
    );
  }
}

class _EmptyHint extends StatelessWidget {
  const _EmptyHint({required this.text});

  final String text;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(18),
      decoration: BoxDecoration(
        color: Colors.grey.shade100,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: Colors.grey.shade300),
      ),
      child: Text(
        text,
        textAlign: TextAlign.center,
        style: TextStyle(color: Colors.grey.shade500, fontSize: 13),
      ),
    );
  }
}

class _LogConsole extends StatelessWidget {
  const _LogConsole({required this.entries, required this.scrollController});

  final List<_LogEntry> entries;
  final ScrollController scrollController;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 190,
      width: double.infinity,
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: const Color(0xFF16121F),
        borderRadius: BorderRadius.circular(16),
      ),
      child: entries.isEmpty
          ? Center(
              child: Text(
                'Logs will appear here...',
                style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
              ),
            )
          : ListView.builder(
              controller: scrollController,
              itemCount: entries.length,
              itemBuilder: (context, index) {
                final e = entries[index];
                final time =
                    '${e.time.hour.toString().padLeft(2, '0')}:${e.time.minute.toString().padLeft(2, '0')}:${e.time.second.toString().padLeft(2, '0')}';
                final color = switch (e.level) {
                  _LogLevel.info => Colors.cyanAccent.shade100,
                  _LogLevel.success => Colors.greenAccent,
                  _LogLevel.error => Colors.redAccent.shade100,
                };
                return Padding(
                  padding: const EdgeInsets.symmetric(vertical: 1.5),
                  child: RichText(
                    text: TextSpan(
                      style: const TextStyle(
                        fontFamily: 'monospace',
                        fontSize: 11.5,
                      ),
                      children: [
                        TextSpan(
                          text: '$time  ',
                          style: TextStyle(color: Colors.grey.shade600),
                        ),
                        TextSpan(
                          text: e.message,
                          style: TextStyle(color: color),
                        ),
                      ],
                    ),
                  ),
                );
              },
            ),
    );
  }
}
2
likes
150
points
142
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

cafebazaar_billing is a Flutter plugin that acts as a gate to Cafe Bazaar's native Android In-App Billing, so Flutter apps can sell products and subscriptions on the Cafe Bazaar store.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on cafebazaar_billing

Packages that implement cafebazaar_billing