blux_flutter 0.1.6 copy "blux_flutter: ^0.1.6" to clipboard
blux_flutter: ^0.1.6 copied to clipboard

BluxClient Flutter SDK

example/lib/main.dart

import 'dart:async';

import 'package:app_links/app_links.dart';
import 'package:blux_flutter/blux_flutter.dart';
import 'package:blux_flutter/blux_flutter_api_stage.dart';
import 'package:blux_flutter/blux_flutter_events/blux_flutter_events.dart';
import 'package:blux_flutter/notifications/inapp_custom_action_event.dart';
import 'package:blux_flutter_example/webview/blux_inapp_webview.dart';
import 'package:blux_flutter_example/webview/blux_webview.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final plugin = BluxFlutter();

  await plugin.setAPIStage(APIStage.stg);

  runApp(MyApp(plugin: plugin));
}

class MyApp extends StatefulWidget {
  final BluxFlutter plugin;
  const MyApp({super.key, required this.plugin});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  BluxFlutter get _plugin => widget.plugin;
  String _platformVersion = 'Unknown';
  late final AppLinks _appLinks;
  StreamSubscription<Uri>? _sub;
  final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
  APIStage _currentStage = APIStage.stg;

  /// Custom HTML 인앱 핸들러 해제 함수
  void Function()? _inAppCustomActionUnsubscribe;

  // Custom Event 입력 컨트롤러
  final _eventTypeController = TextEditingController();
  final _propKeyController = TextEditingController();
  final _propValueController = TextEditingController();

  // 상태 추적
  bool _isInitialized = false;
  String? _signedInUserId;

  // Prod credentials
  static const _prodApplicationId = '693271b484763c6fb1a85054';
  static const _prodApiKey = 'CDtIFIKIk02Dm3jIFMKQXmGvKRzDhQcUOGN_XUso';

  // Stg credentials
  static const _stgApplicationId = '69327603beb1da48e4278eca';
  static const _stgApiKey = 'EPaHV6oaJZmbwpvY_i6EmHrw8Sq5KhE0iaOZ7ICE';

  @override
  void initState() {
    super.initState();
    _initPlatformState();
    _initDeepLinks();
  }

  // Platform messages are asynchronous, so we initialize in an async method.
  Future<void> _initPlatformState() async {
    String version;
    // Platform messages may fail, so we use a try/catch PlatformException.
    // We also handle the message potentially returning null.
    try {
      version =
          await _plugin.getPlatformVersion() ?? 'Unknown platform version';
    } on PlatformException {
      version = 'Failed to get platform version.';
    }

    // If the widget was removed from the tree while the asynchronous platform
    // message was in flight, we want to discard the reply rather than calling
    // setState to update our non-existent appearance.
    if (!mounted) return;

    setState(() {
      _platformVersion = version;
    });
  }

  Future<void> _initDeepLinks() async {
    _appLinks = AppLinks();

    final initialUri = await _appLinks.getInitialLink();
    if (initialUri != null) {
      _handleDeepLink(initialUri);
    }

    _sub = _appLinks.uriLinkStream.listen(
      (uri) => _handleDeepLink(uri),
      onError: (e) {
        debugPrint('Deep link error: $e');
      },
    );
  }

  void _handleDeepLink(Uri uri) {
    // blux://open/https/fmkorea.com/path1/path2?param1=value1&param2=value2

    if (uri.scheme != 'blux') return;
    if (uri.host != 'open') return;

    final segments = uri.pathSegments; // [https, fmkorea.com, path1, path2]
    if (segments.length < 2) return;

    final scheme = segments[0]; // https
    final host = segments[1]; // fmkorea.com
    final path = segments.length > 2
        ? '/${segments.sublist(2).join('/')}'
        : ''; // /path1/path2
    final navUri = Uri(
      scheme: scheme,
      host: host,
      path: path,
      queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters,
    );

    final nav = _navigatorKey.currentState;
    if (nav == null) return;
    nav.push(
      MaterialPageRoute(
        builder: (_) => BluxInAppWebView(initialUrl: navUri.toString()),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final isProd = _currentStage == APIStage.prod;
    final currentAppId = isProd ? _prodApplicationId : _stgApplicationId;

    return MaterialApp(
      navigatorKey: _navigatorKey,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Blux SDK for Flutter'),
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        ),
        body: SingleChildScrollView(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              // Status 영역
              _buildStatusCard(currentAppId),
              const SizedBox(height: 16),

              // Stage 선택
              _buildSectionCard(
                title: 'Stage',
                child: Row(
                  children: [
                    Expanded(
                      child: _buildStageButton('Production', APIStage.prod),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: _buildStageButton('Staging', APIStage.stg),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 12),

              // User 섹션
              _buildSectionCard(
                title: 'User',
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    _buildPrimaryButton(
                      label: 'Initialize',
                      icon: Icons.play_arrow,
                      onPressed: _handleInitialize,
                    ),
                    const SizedBox(height: 10),
                    Row(
                      children: [
                        Expanded(
                          child: _buildSecondaryButton(
                            label: 'Sign In',
                            onPressed: _showSignInDialog,
                          ),
                        ),
                        const SizedBox(width: 8),
                        Expanded(
                          child: _buildSecondaryButton(
                            label: 'Sign Out',
                            onPressed: _handleSignOut,
                          ),
                        ),
                      ],
                    ),
                    const SizedBox(height: 8),
                    Row(
                      children: [
                        Expanded(
                          child: _buildSecondaryButton(
                            label: 'User Props',
                            onPressed: _showSetUserPropertiesDialog,
                          ),
                        ),
                        const SizedBox(width: 8),
                        Expanded(
                          child: _buildSecondaryButton(
                            label: 'Custom Props',
                            onPressed: _showSetCustomUserPropertiesDialog,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 12),

              // Events 섹션
              _buildSectionCard(
                title: 'Events',
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    // Custom Event 입력 필드
                    TextField(
                      controller: _eventTypeController,
                      decoration: InputDecoration(
                        labelText: 'Event Type',
                        hintText: 'custom_event',
                        filled: true,
                        fillColor: Colors.grey.shade100,
                        border: OutlineInputBorder(
                          borderRadius: BorderRadius.circular(10),
                          borderSide: BorderSide.none,
                        ),
                        contentPadding: const EdgeInsets.symmetric(
                          horizontal: 14,
                          vertical: 12,
                        ),
                      ),
                    ),
                    const SizedBox(height: 10),
                    Row(
                      children: [
                        Expanded(
                          child: TextField(
                            controller: _propKeyController,
                            decoration: InputDecoration(
                              labelText: 'Prop Key',
                              hintText: 'key',
                              filled: true,
                              fillColor: Colors.grey.shade100,
                              border: OutlineInputBorder(
                                borderRadius: BorderRadius.circular(10),
                                borderSide: BorderSide.none,
                              ),
                              contentPadding: const EdgeInsets.symmetric(
                                horizontal: 14,
                                vertical: 12,
                              ),
                            ),
                          ),
                        ),
                        const SizedBox(width: 8),
                        Expanded(
                          child: TextField(
                            controller: _propValueController,
                            decoration: InputDecoration(
                              labelText: 'Prop Value',
                              hintText: 'value',
                              filled: true,
                              fillColor: Colors.grey.shade100,
                              border: OutlineInputBorder(
                                borderRadius: BorderRadius.circular(10),
                                borderSide: BorderSide.none,
                              ),
                              contentPadding: const EdgeInsets.symmetric(
                                horizontal: 14,
                                vertical: 12,
                              ),
                            ),
                          ),
                        ),
                      ],
                    ),
                    const SizedBox(height: 10),
                    _buildPrimaryButton(
                      label: 'Send Custom Event',
                      icon: Icons.send,
                      onPressed: _sendCustomEvent,
                    ),
                    const SizedBox(height: 12),
                    const Divider(),
                    const SizedBox(height: 8),
                    Wrap(
                      spacing: 8,
                      runSpacing: 8,
                      children: [
                        _buildEventChip('PDV', () {
                          _plugin.sendEvent(AddProductDetailViewEvent(itemId: '1234'));
                          _showToast('ProductDetailView Sent');
                        }),
                        _buildEventChip('CartAdd', () {
                          _plugin.sendEvent(AddCartaddEvent(itemId: '1234'));
                          _showToast('CartAdd Sent');
                        }),
                        _buildEventChip('Order', () {
                          _plugin.sendEvent(AddOrderEvent(
                            orderAmount: 200,
                            paidAmount: 100,
                            items: [{'id': '1234', 'price': 200, 'quantity': 1}],
                            orderId: 'order_${DateTime.now().millisecondsSinceEpoch}',
                          ));
                          _showToast('Order Sent');
                        }),
                      ],
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 12),

              // WebView 섹션
              _buildSectionCard(
                title: 'WebView',
                child: Row(
                  children: [
                    Expanded(
                      child: Builder(
                        builder: (ctx) => _buildSecondaryButton(
                          label: 'webview_flutter',
                          onPressed: () => Navigator.push(
                            ctx,
                            MaterialPageRoute(
                              builder: (_) => BluxWebView(initialUrl: _buildWebViewUrl()),
                            ),
                          ),
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: Builder(
                        builder: (ctx) => _buildSecondaryButton(
                          label: 'inappwebview',
                          onPressed: () => Navigator.push(
                            ctx,
                            MaterialPageRoute(
                              builder: (_) => BluxInAppWebView(initialUrl: _buildWebViewUrl()),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 12),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildStatusCard(String appId) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.grey.shade50,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: Colors.grey.shade200),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Container(
                width: 10,
                height: 10,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: _isInitialized ? Colors.green : Colors.grey.shade400,
                ),
              ),
              const SizedBox(width: 8),
              Text(
                _isInitialized ? 'Initialized' : 'Not Initialized',
                style: TextStyle(
                  fontWeight: FontWeight.w600,
                  fontSize: 14,
                  color: _isInitialized ? Colors.green.shade700 : Colors.grey.shade600,
                ),
              ),
            ],
          ),
          const SizedBox(height: 12),
          _buildStatusRow('Platform', _platformVersion),
          _buildStatusRow('Stage', _currentStage.name.toUpperCase()),
          _buildStatusRow('App ID', appId),
          _buildStatusRow('User ID', _signedInUserId ?? '(not signed in)'),
        ],
      ),
    );
  }

  Widget _buildStatusRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 2),
      child: Row(
        children: [
          SizedBox(
            width: 70,
            child: Text(
              '$label:',
              style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
            ),
          ),
          Expanded(
            child: Text(
              value,
              style: const TextStyle(fontSize: 12),
              overflow: TextOverflow.ellipsis,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSectionCard({required String title, required Widget child}) {
    return Card(
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              title,
              style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
            ),
            const SizedBox(height: 14),
            child,
          ],
        ),
      ),
    );
  }

  Widget _buildStageButton(String label, APIStage stage) {
    final isSelected = _currentStage == stage;
    return GestureDetector(
      onTap: () {
        setState(() => _currentStage = stage);
        _plugin.setAPIStage(stage);
      },
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 12),
        decoration: BoxDecoration(
          color: isSelected ? Colors.blue : Colors.grey.shade200,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Center(
          child: Text(
            label,
            style: TextStyle(
              color: isSelected ? Colors.white : Colors.black87,
              fontWeight: FontWeight.w500,
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildPrimaryButton({
    required String label,
    required VoidCallback onPressed,
    IconData? icon,
  }) {
    return GestureDetector(
      onTap: onPressed,
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 14),
        decoration: BoxDecoration(
          color: Colors.blue,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            if (icon != null) ...[
              Icon(icon, color: Colors.white, size: 20),
              const SizedBox(width: 8),
            ],
            Text(
              label,
              style: const TextStyle(
                color: Colors.white,
                fontWeight: FontWeight.w600,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildSecondaryButton({
    required String label,
    required VoidCallback onPressed,
  }) {
    return GestureDetector(
      onTap: onPressed,
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 12),
        decoration: BoxDecoration(
          color: Colors.grey.shade100,
          borderRadius: BorderRadius.circular(10),
          border: Border.all(color: Colors.grey.shade300),
        ),
        child: Center(
          child: Text(
            label,
            style: TextStyle(
              color: Colors.grey.shade700,
              fontWeight: FontWeight.w500,
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildEventChip(String label, VoidCallback onPressed) {
    return GestureDetector(
      onTap: onPressed,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        decoration: BoxDecoration(
          color: Colors.blue.shade50,
          borderRadius: BorderRadius.circular(20),
          border: Border.all(color: Colors.blue.shade200),
        ),
        child: Text(
          label,
          style: TextStyle(
            color: Colors.blue.shade700,
            fontWeight: FontWeight.w500,
          ),
        ),
      ),
    );
  }

  Future<void> _handleInitialize() async {
    final isProd = _currentStage == APIStage.prod;
    await _plugin.initialize(
      bluxApplicationId: isProd ? _prodApplicationId : _stgApplicationId,
      bluxAPIKey: isProd ? _prodApiKey : _stgApiKey,
      requestPermissionsOnLaunch: true,
    );

    _inAppCustomActionUnsubscribe = await _plugin.addInAppCustomActionHandler(
      _handleInAppCustomAction,
    );

    setState(() => _isInitialized = true);
    _showToast('Initialized');
  }

  void _handleSignOut() {
    _plugin.signOut();
    setState(() => _signedInUserId = null);
    _showToast('Signed Out');
  }

  void _showToast(String message) {
    ScaffoldMessenger.of(_navigatorKey.currentContext!).showSnackBar(
      SnackBar(content: Text(message), duration: const Duration(seconds: 1)),
    );
  }

  String _buildWebViewUrl() {
    final isProd = _currentStage == APIStage.prod;
    final stage = isProd ? 'prod' : 'stg';
    final applicationId = isProd ? _prodApplicationId : _stgApplicationId;
    final apiKey = isProd ? _prodApiKey : _stgApiKey;
    return 'https://stg.sdk-demo.blux.ai/?application_id=$applicationId&api_key=$apiKey&stage=$stage&platform=flutter';
  }

  void _showSignInDialog() {
    final dialogContext = _navigatorKey.currentState!.overlay!.context;

    showDialog<String>(
      context: dialogContext,
      builder: (ctx) {
        String v = '';
        bool okEnabled() => v.trim().isNotEmpty;
        return StatefulBuilder(
          builder: (ctx, setState) => AlertDialog(
            title: const Text('Sign In'),
            content: TextField(
              autofocus: true,
              onChanged: (s) => setState(() => v = s),
              onSubmitted: (_) {
                if (okEnabled()) {
                  Navigator.pop(ctx, v.trim());
                }
              },
              decoration: const InputDecoration(hintText: 'userId'),
            ),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(ctx),
                child: const Text('Cancel'),
              ),
              TextButton(
                onPressed: okEnabled()
                    ? () => Navigator.pop(ctx, v.trim())
                    : null,
                child: const Text('OK'),
              ),
            ],
          ),
        );
      },
    ).then((userId) {
      if (userId != null && userId.isNotEmpty) {
        _plugin.signIn(userId: userId);
        setState(() => _signedInUserId = userId);
        _showToast('Signed In: $userId');
      }
    });
  }

  Future<void> _showSetUserPropertiesDialog() async {
    final dialogContext = _navigatorKey.currentState!.overlay!.context;

    final phoneCtrl = TextEditingController();
    final emailCtrl = TextEditingController();

    bool? c, sms, email, push, kakao;

    bool? triNext(bool? v) => v == null ? true : (v ? false : null);

    Widget triButton({
      required String keyName,
      required bool? value,
      required VoidCallback onPressed,
    }) {
      final label = value == null
          ? keyName
          : '$keyName: ${value ? 'true' : 'false'}';
      return value == null
          ? OutlinedButton(onPressed: onPressed, child: Text(label)) // 무색
          : ElevatedButton(onPressed: onPressed, child: Text(label)); // 유색
    }

    final props = await showDialog<UserProperties>(
      context: dialogContext,
      builder: (ctx) => StatefulBuilder(
        builder: (ctx, setState) {
          bool hasAny() =>
              phoneCtrl.text.trim().isNotEmpty ||
              emailCtrl.text.trim().isNotEmpty ||
              [c, sms, email, push, kakao].any((v) => v != null);

          return AlertDialog(
            title: const Text('Set UserProperties'),
            content: SingleChildScrollView(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  TextField(
                    controller: phoneCtrl,
                    onChanged: (_) => setState(() {}),
                    decoration: const InputDecoration(
                      labelText: 'phoneNumber',
                      hintText: '01012345678',
                    ),
                  ),
                  const SizedBox(height: 8),
                  TextField(
                    controller: emailCtrl,
                    onChanged: (_) => setState(() {}),
                    decoration: const InputDecoration(
                      labelText: 'emailAddress',
                      hintText: 'test@example.com',
                    ),
                  ),
                  const SizedBox(height: 12),

                  triButton(
                    keyName: 'marketing_notification_consent',
                    value: c,
                    onPressed: () => setState(() => c = triNext(c)),
                  ),
                  triButton(
                    keyName: 'marketing_notification_sms_consent',
                    value: sms,
                    onPressed: () => setState(() => sms = triNext(sms)),
                  ),
                  triButton(
                    keyName: 'marketing_notification_email_consent',
                    value: email,
                    onPressed: () => setState(() => email = triNext(email)),
                  ),
                  triButton(
                    keyName: 'marketing_notification_push_consent',
                    value: push,
                    onPressed: () => setState(() => push = triNext(push)),
                  ),
                  triButton(
                    keyName: 'marketing_notification_kakao_consent',
                    value: kakao,
                    onPressed: () => setState(() => kakao = triNext(kakao)),
                  ),
                ],
              ),
            ),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(ctx),
                child: const Text('Cancel'),
              ),
              TextButton(
                onPressed: hasAny()
                    ? () {
                        Navigator.pop(
                          ctx,
                          UserProperties(
                            phoneNumber: phoneCtrl.text.trim().isEmpty
                                ? null
                                : phoneCtrl.text.trim(),
                            emailAddress: emailCtrl.text.trim().isEmpty
                                ? null
                                : emailCtrl.text.trim(),
                            marketingNotificationConsent: c,
                            marketingNotificationSmsConsent: sms,
                            marketingNotificationEmailConsent: email,
                            marketingNotificationPushConsent: push,
                            marketingNotificationKakaoConsent: kakao,
                          ),
                        );
                      }
                    : null,
                child: const Text('OK'),
              ),
            ],
          );
        },
      ),
    );

    if (props != null) {
      await _plugin.setUserProperties(props);
    }
  }

  Future<void> _showSetCustomUserPropertiesDialog() async {
    final dialogContext = _navigatorKey.currentState!.overlay!.context;

    final stringRows = <_PropRow>[_PropRow()];
    final numberRows = <_PropRow>[_PropRow()];
    final boolRows = <_PropRow>[_PropRow()];

    bool hasAnyKey() =>
        stringRows.any((e) => e.key.trim().isNotEmpty) ||
        numberRows.any((e) => e.key.trim().isNotEmpty) ||
        boolRows.any((e) => e.key.trim().isNotEmpty);

    Map<String, dynamic> buildMap() {
      final m = <String, dynamic>{};

      for (final r in stringRows) {
        final k = r.key.trim();
        if (k.isEmpty) continue;
        m[k] = r.stringValue;
      }

      for (final r in numberRows) {
        final k = r.key.trim();
        if (k.isEmpty) continue;
        final t = r.numberValueText.trim();
        if (t.isEmpty) continue;
        final n = double.tryParse(t);
        if (n == null) continue;
        m[k] = n;
      }

      for (final r in boolRows) {
        final k = r.key.trim();
        if (k.isEmpty) continue;
        m[k] = r.boolValue;
      }

      return m;
    }

    final props = await showDialog<Map<String, dynamic>>(
      context: dialogContext,
      builder: (ctx) => StatefulBuilder(
        builder: (ctx, setState) {
          void close([Map<String, dynamic>? value]) {
            FocusScope.of(ctx).unfocus();
            Navigator.pop(ctx, value);
          }

          Widget sectionHeader(String title) => Padding(
            padding: const EdgeInsets.only(top: 12, bottom: 8),
            child: Align(
              alignment: Alignment.centerLeft,
              child: Text(title, style: Theme.of(ctx).textTheme.titleMedium),
            ),
          );

          Widget addButton(VoidCallback onAdd) => Align(
            alignment: Alignment.centerLeft,
            child: OutlinedButton.icon(
              onPressed: onAdd,
              icon: const Icon(Icons.add),
              label: const Text('Add'),
            ),
          );

          Widget removeButton({
            required bool enabled,
            required VoidCallback onRemove,
          }) => IconButton(
            tooltip: 'Remove',
            onPressed: enabled ? onRemove : null,
            icon: const Icon(Icons.remove_circle_outline),
          );

          Widget stringRowItem(int index) {
            final r = stringRows[index];
            return Row(
              children: [
                Expanded(
                  child: TextFormField(
                    key: ValueKey('s_k_${r.id}'),
                    initialValue: r.key,
                    onChanged: (v) => setState(() => r.key = v),
                    decoration: const InputDecoration(
                      labelText: 'key',
                      hintText: 'nickname',
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: TextFormField(
                    key: ValueKey('s_v_${r.id}'),
                    initialValue: r.stringValue,
                    onChanged: (v) => setState(() => r.stringValue = v),
                    decoration: const InputDecoration(
                      labelText: 'value',
                      hintText: 'Cristiano',
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                removeButton(
                  enabled: stringRows.length > 1,
                  onRemove: () => setState(() => stringRows.removeAt(index)),
                ),
              ],
            );
          }

          final numberFmt = FilteringTextInputFormatter.allow(
            RegExp(r'^\d*\.?\d*$'), // 숫자 + 소수점 1개
          );

          Widget numberRowItem(int index) {
            final r = numberRows[index];
            return Row(
              children: [
                Expanded(
                  child: TextFormField(
                    key: ValueKey('n_k_${r.id}'),
                    initialValue: r.key,
                    onChanged: (v) => setState(() => r.key = v),
                    decoration: const InputDecoration(
                      labelText: 'key',
                      hintText: 'height',
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: TextFormField(
                    key: ValueKey('n_v_${r.id}'),
                    initialValue: r.numberValueText,
                    keyboardType: const TextInputType.numberWithOptions(
                      decimal: true,
                      signed: false,
                    ),
                    inputFormatters: [numberFmt],
                    onChanged: (v) => setState(() => r.numberValueText = v),
                    decoration: const InputDecoration(
                      labelText: 'value',
                      hintText: '177.7',
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                removeButton(
                  enabled: numberRows.length > 1,
                  onRemove: () => setState(() => numberRows.removeAt(index)),
                ),
              ],
            );
          }

          Widget boolRowItem(int index) {
            final r = boolRows[index];
            return Row(
              children: [
                Expanded(
                  child: TextFormField(
                    key: ValueKey('b_k_${r.id}'),
                    initialValue: r.key,
                    onChanged: (v) => setState(() => r.key = v),
                    decoration: const InputDecoration(
                      labelText: 'key',
                      hintText: 'is_active',
                    ),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: DropdownButtonFormField<bool>(
                    key: ValueKey('b_v_${r.id}'),
                    value: r.boolValue,
                    items: const [
                      DropdownMenuItem(value: true, child: Text('true')),
                      DropdownMenuItem(value: false, child: Text('false')),
                    ],
                    onChanged: (v) => setState(() => r.boolValue = v ?? true),
                    decoration: const InputDecoration(labelText: 'value'),
                  ),
                ),
                const SizedBox(width: 8),
                removeButton(
                  enabled: boolRows.length > 1,
                  onRemove: () => setState(() => boolRows.removeAt(index)),
                ),
              ],
            );
          }

          return AlertDialog(
            title: const Text('Set CustomUserProperties'),
            content: SizedBox(
              width: 560,
              child: SingleChildScrollView(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    sectionHeader('String'),
                    ...List.generate(stringRows.length, stringRowItem),
                    const SizedBox(height: 8),
                    addButton(() => setState(() => stringRows.add(_PropRow()))),

                    sectionHeader('Number'),
                    ...List.generate(numberRows.length, numberRowItem),
                    const SizedBox(height: 8),
                    addButton(() => setState(() => numberRows.add(_PropRow()))),

                    sectionHeader('Boolean'),
                    ...List.generate(boolRows.length, boolRowItem),
                    const SizedBox(height: 8),
                    addButton(() => setState(() => boolRows.add(_PropRow()))),
                  ],
                ),
              ),
            ),
            actions: [
              TextButton(onPressed: () => close(), child: const Text('Cancel')),
              TextButton(
                onPressed: hasAnyKey() ? () => close(buildMap()) : null,
                child: const Text('OK'),
              ),
            ],
          );
        },
      ),
    );

    if (props != null && props.isNotEmpty) {
      await _plugin.setCustomUserProperties(props);
    }
  }

  /// Custom HTML 인앱 메시지에서 BluxBridge.triggerAction() 호출 시 실행
  void _handleInAppCustomAction(InAppCustomActionEvent event) {
    debugPrint('InAppCustomAction received:');
    debugPrint('  actionId: ${event.actionId}');
    debugPrint('  data: ${event.data}');

    // data를 문자열로 변환
    final dataString = event.data.entries
        .map((e) => '${e.key}: ${e.value}')
        .join(', ');
    final message = 'actionId: ${event.actionId}\ndata: {$dataString}';

    // dismiss 애니메이션 완료 후 alert 표시 (300ms 지연)
    Future.delayed(const Duration(milliseconds: 300), () {
      final context = _navigatorKey.currentState?.overlay?.context;
      if (context == null) return;

      showDialog(
        context: context,
        builder: (ctx) => AlertDialog(
          title: const Text('Custom Action'),
          content: Text(message),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(ctx),
              child: const Text('OK'),
            ),
          ],
        ),
      );
    });
  }

  void _sendCustomEvent() {
    final eventType = _eventTypeController.text.trim();
    if (eventType.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Event Type is required')),
      );
      return;
    }

    final propKey = _propKeyController.text.trim();
    final propValue = _propValueController.text.trim();

    Map<String, dynamic>? customProps;
    if (propKey.isNotEmpty && propValue.isNotEmpty) {
      customProps = {propKey: propValue};
    }

    _plugin.sendEvent(
      AddCustomEvent(
        eventType: eventType,
        customEventProperties: customProps,
      ),
    );

    debugPrint('Custom Event Sent: $eventType');

    // 키보드 숨기기 & 토스트 표시
    final messenger = ScaffoldMessenger.of(context);
    FocusScope.of(context).unfocus();
    messenger.showSnackBar(
      SnackBar(content: Text('Custom Event Sent: $eventType')),
    );
  }

  @override
  void dispose() {
    _sub?.cancel();
    _inAppCustomActionUnsubscribe?.call();
    _eventTypeController.dispose();
    _propKeyController.dispose();
    _propValueController.dispose();
    super.dispose();
  }
}

class _PropRow {
  final String id = UniqueKey().toString();
  String key = '';
  String stringValue = '';
  String numberValueText = '';
  bool boolValue = true;
  _PropRow();
}