octopus_sdk_flutter 1.11.0 copy "octopus_sdk_flutter: ^1.11.0" to clipboard
octopus_sdk_flutter: ^1.11.0 copied to clipboard

Flutter plugin for Octopus Community SDK

example/lib/main.dart

import 'dart:convert';
import 'dart:io' show Platform;

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'login_page.dart';
import 'profile_edit_page.dart';
import 'secrets.dart';

/// MethodChannel exposed by the iOS AppDelegate to forward APNs token + tap events.
const MethodChannel _pushChannel =
    MethodChannel('octopus_sdk_flutter_example/push');

/// Navigator key so push-notification handlers can call `openNotification`
/// outside of a widget build context (e.g. during cold-start handling).
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Firebase Messaging powers the Android push path. iOS uses the native
  // APNs wiring in AppDelegate.swift; Firebase is not initialized on iOS in
  // this example because no GoogleService-Info.plist is bundled.
  if (Platform.isAndroid) {
    try {
      await Firebase.initializeApp();
    } catch (e) {
      debugPrint('[Push] Firebase.initializeApp failed: $e');
    }
  }
  runApp(const OctopusApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey,
      title: 'Octopus SDK Sample App',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF4F46E5), // indigo-ish
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

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

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {

  final octopus = OctopusSDK();
  bool _isInitializing = false;
  bool _isInitialized = false;
  bool _isUserConnected = false;
  String? logo;
  int _currentTabIndex = 0;
  int _notSeenCount = 0;
  bool? _hasAccessToCommunity;

  /// Index of the Community tab in the bottom nav.
  static const int _communityTabIndex = 1;

  /// One-shot deep link for the embedded view. Set when a notification is
  /// tapped, consumed on the next build of the Community tab, then cleared.
  OctopusNotification? _pendingCommunityNotification;

  static const String _userConnectedKey = 'isUserConnected';

  @override
  void initState() {
    super.initState();

    OctopusSDK.notSeenNotificationsCount.listen((count) {
      debugPrint('[OCT-1142] notSeenNotificationsCount received: $count');
      if (mounted) setState(() => _notSeenCount = count);
    });
    OctopusSDK.hasAccessToCommunity.listen((hasAccess) {
      if (mounted) setState(() => _hasAccessToCommunity = hasAccess);
    });
    OctopusSDK.events.listen((event) {
      if (event is PostCreatedEvent && mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Octopus Post created: ${event.postId}')),
        );
      }
    });

    // Push notification wiring.
    //   - iOS: native AppDelegate forwards APNs token + taps through the
    //     `_pushChannel` MethodChannel.
    //   - Android: Firebase Messaging delivers FCM messages and token
    //     refreshes, wired below in `_setupFirebaseMessaging`.
    if (Platform.isIOS) {
      _pushChannel.setMethodCallHandler(_onPushChannelCall);
    } else if (Platform.isAndroid) {
      _setupFirebaseMessaging();
    }

    WidgetsBinding.instance.addPostFrameCallback((_) async {
      final byteData = await rootBundle.load('assets/logo.png');
      setState(() {
        logo = base64Encode(byteData.buffer.asUint8List());
      });

      // Load saved user connection state
      await _loadUserConnectionState();

      // Initialize SDK automatically on app start
      await _initOctopus();

      // After init: drain any cold-start notification tap. We do this after
      // _initOctopus so the SDK is ready to receive the openNotification call.
      if (Platform.isIOS) {
        await _checkInitialNotification();
      } else if (Platform.isAndroid) {
        await _checkInitialFirebaseMessage();
      }
    });
  }

  Future<void> _setupFirebaseMessaging() async {
    final messaging = FirebaseMessaging.instance;

    // Android 13+ requires runtime POST_NOTIFICATIONS permission. On older
    // Android versions this resolves immediately as authorized.
    final settings = await messaging.requestPermission();
    debugPrint('[Push] FCM authorization status: ${settings.authorizationStatus}');

    // Register the FCM token with Octopus. The token can change at any time,
    // so listen for refreshes as well.
    Future<void> registerFcmToken(String? token) async {
      if (token == null || token.isEmpty) return;
      debugPrint('[Push] FCM token received: $token');
      try {
        await octopus.registerPushNotificationToken(token);
        debugPrint('[Push] FCM token registered with Octopus');
      } catch (e) {
        debugPrint('[Push] registerPushNotificationToken failed: $e');
      }
    }

    try {
      await registerFcmToken(await messaging.getToken());
    } catch (e) {
      debugPrint('[Push] FCM getToken failed: $e');
    }
    messaging.onTokenRefresh.listen(registerFcmToken);

    // Foreground delivery — FCM does NOT auto-show a banner; we route
    // straight into the in-app handler so the user lands on the deep link.
    FirebaseMessaging.onMessage.listen((message) {
      debugPrint('[Push] FCM foreground message: ${message.data}');
      _handleOctopusNotification(_payloadFromFcm(message));
    });

    // Tap from background (notification was shown by the OS while app was
    // in the background and user tapped it).
    FirebaseMessaging.onMessageOpenedApp.listen((message) {
      debugPrint('[Push] FCM tapped (background): ${message.data}');
      _handleOctopusNotification(_payloadFromFcm(message));
    });
  }

  Future<void> _checkInitialFirebaseMessage() async {
    try {
      final initial = await FirebaseMessaging.instance.getInitialMessage();
      if (initial != null) {
        debugPrint('[Push] FCM cold-start message: ${initial.data}');
        _handleOctopusNotification(_payloadFromFcm(initial));
      }
    } catch (e) {
      debugPrint('[Push] FCM getInitialMessage failed: $e');
    }
  }

  /// Combines an FCM `RemoteMessage` into a single map matching the SDK's
  /// expected shape. `RemoteMessage.data` carries the Octopus keys; the
  /// top-level `notification` field carries title/body (the cross-platform
  /// equivalent of iOS APNs `aps.alert`). Merging them so the Flutter SDK
  /// can render copy without the backend duplicating into `data`.
  Map<String, Object?> _payloadFromFcm(RemoteMessage message) {
    final payload = <String, Object?>{...message.data};
    final notification = message.notification;
    if (notification != null) {
      final t = notification.title;
      final b = notification.body;
      if (t != null) payload.putIfAbsent('title', () => t);
      if (b != null) payload.putIfAbsent('body', () => b);
    }
    return payload;
  }

  Future<dynamic> _onPushChannelCall(MethodCall call) async {
    switch (call.method) {
      case 'apnsToken':
        final token = call.arguments as String?;
        if (token == null || token.isEmpty) return null;
        debugPrint('[Push] APNs token received: $token');
        try {
          await octopus.registerPushNotificationToken(token);
          debugPrint('[Push] APNs token registered with Octopus');
        } catch (e) {
          debugPrint('[Push] registerPushNotificationToken failed: $e');
        }
        return null;
      case 'notificationTapped':
        final raw = call.arguments;
        if (raw is Map) _handleOctopusNotification(raw);
        return null;
    }
    return null;
  }

  Future<void> _checkInitialNotification() async {
    try {
      final raw = await _pushChannel.invokeMethod('getInitialNotification');
      if (raw is Map) {
        debugPrint('[Push] cold-start notification: $raw');
        _handleOctopusNotification(raw);
      }
    } catch (e) {
      debugPrint('[Push] getInitialNotification failed: $e');
    }
  }

  /// Hands the raw push payload to the Octopus SDK. The SDK handles both
  /// Android FCM (flat `RemoteMessage.data`) and iOS raw APNs `userInfo`
  /// (nested `aps` + `data` envelope) shapes — no consumer-side glue needed.
  void _handleOctopusNotification(Map payload) {
    debugPrint('[Push] handling notification: $payload');
    if (!OctopusSDK.isOctopusNotification(payload)) {
      debugPrint('[Push] not an Octopus notification — ignoring');
      return;
    }
    final notification = OctopusSDK.getOctopusNotification(payload);
    if (notification == null) {
      debugPrint('[Push] failed to parse OctopusNotification');
      return;
    }
    if (!mounted) return;
    setState(() {
      _pendingCommunityNotification = notification;
      _currentTabIndex = _communityTabIndex;
    });
  }

  Future<void> _loadUserConnectionState() async {
    final prefs = await SharedPreferences.getInstance();
    final isConnected = prefs.getBool(_userConnectedKey) ?? false;
    if (mounted) {
      setState(() => _isUserConnected = isConnected);
    }
  }

  Future<void> _saveUserConnectionState(bool isConnected) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_userConnectedKey, isConnected);
  }

  Future<void> _initOctopus() async {
    if (_isInitialized || _isInitializing) return;
    setState(() => _isInitializing = true);

    try {
      // Initialize SDK
      await octopus.initialize(
        // Change it in secrets.dart
        apiKey: octopusApiKey,
        // User profile properties managed by your app (optional)
        // a combination of
        // 'PICTURE',
        //  'BIO',
        //  'NICKNAME',
        appManagedFields: [ProfileField.nickname],  // e.g. [ProfileField.nickname, ProfileField.picture, ProfileField.bio]
      );

      setState(() => _isInitialized = true);

      // Connect the user in Octopus if he is connected in app
      if (_isUserConnected) {
        await _connectUser();
      }

      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Octopus SDK initialized')),
        );
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Init failed: $e')),
        );
      }
    } finally {
      if (mounted) setState(() => _isInitializing = false);
    }
  }

  Future<void> _connectUser() async {
    if (!_isInitialized || _isInitializing) return;

    try {
      // Connect user
      await octopus.connectUser(
        userId: "YOUR_INTERNAL_USER_ID",
        // Your backend should provide a jwt when authentifiyng a user
        // cf. https://doc.octopuscommunity.com/backend/sso
        token: octopusUserToken, // Stored in secrets.dart FOR SAMPLE USAGE
        // nickname: "Example username", // optional if NICKNAME is not present in appManagedFields at init
        // bio: 'SSO user example bio', // optional if BIO is not present in appManagedFields at init
        // picture: 'https://...', // optional if PICTURE is not present in appManagedFields at init
      );

      setState(() => _isUserConnected = true);
      await _saveUserConnectionState(true);

      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('User connected')),
        );
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Connection failed: $e')),
        );
      }
    }
  }

  Future<void> _disconnectUser() async {
    try {
      await octopus.disconnectUser();
      setState(() => _isUserConnected = false);
      await _saveUserConnectionState(false);

      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('User disconnected')),
        );
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Disconnect failed: $e')),
        );
      }
    }
  }


  Widget _buildConfigPage() {
    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.all(32),
        child: SingleChildScrollView(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Header with description
            Card(
              color: Colors.deepPurple.shade50,
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Row(
                      children: [
                        Icon(Icons.widgets, color: Colors.deepPurple.shade700),
                        const SizedBox(width: 8),
                        Text(
                          'Embedded Widget Mode',
                          style: TextStyle(
                            fontSize: 18,
                            fontWeight: FontWeight.bold,
                            color: Colors.deepPurple.shade700,
                          ),
                        ),
                      ],
                    ),
                    const SizedBox(height: 8),
                    const Text(
                      'The Octopus interface is integrated directly into your Flutter application '
                      'as a widget. Ideal for integration into your existing interface.',
                      style: TextStyle(fontSize: 14),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),

            // SDK Status
            Card(
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    const Text(
                      'SDK Status:',
                      style: TextStyle(fontWeight: FontWeight.bold),
                    ),
                    const SizedBox(height: 8),
                    Row(
                      children: [
                        Icon(
                          _isInitialized ? Icons.check_circle : Icons.error,
                          color: _isInitialized ? Colors.green : Colors.red,
                          size: 16,
                        ),
                        const SizedBox(width: 4),
                        Text(
                          _isInitialized ? 'Initialized' : 'Not initialized',
                          style: TextStyle(
                            color: _isInitialized ? Colors.green : Colors.red,
                            fontWeight: FontWeight.w500,
                          ),
                        ),
                      ],
                    ),
                    if (_isInitialized) ...[
                      const SizedBox(height: 8),
                      Row(
                        children: [
                          Icon(
                            Icons.notifications,
                            color: _notSeenCount > 0 ? Colors.orange : Colors.grey,
                            size: 16,
                          ),
                          const SizedBox(width: 4),
                          Expanded(
                            child: Text(
                              'Unread notifications: $_notSeenCount',
                              style: const TextStyle(fontWeight: FontWeight.w500),
                            ),
                          ),
                          SizedBox(
                            height: 28,
                            child: TextButton(
                              onPressed: () async {
                                await octopus.updateNotSeenNotificationsCount();
                                if (mounted) {
                                  ScaffoldMessenger.of(context).showSnackBar(
                                    const SnackBar(content: Text('Notification count refreshed')),
                                  );
                                }
                              },
                              child: const Text('Refresh', style: TextStyle(fontSize: 12)),
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 8),
                      Row(
                        children: [
                          Icon(
                            _hasAccessToCommunity == true ? Icons.lock_open : Icons.lock,
                            color: _hasAccessToCommunity == true ? Colors.green : Colors.grey,
                            size: 16,
                          ),
                          const SizedBox(width: 4),
                          Text(
                            'Community access: ${_hasAccessToCommunity ?? 'unknown'}',
                            style: const TextStyle(fontWeight: FontWeight.w500),
                          ),
                        ],
                      ),
                    ],
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),

            // Action buttons
            if (_isInitialized)
              SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  onPressed: _isUserConnected ? _disconnectUser : _connectUser,
                  style: _isUserConnected ? ElevatedButton.styleFrom(
                    backgroundColor: Colors.red.shade100,
                    foregroundColor: Colors.red.shade700,
                  ) : null,
                  child: Text(
                      _isUserConnected ? 'Disconnect User' : 'Connect User'),
                ),
              ),

            const SizedBox(height: 16),

          ],
        ),
      ),
      ),
    );
  }

  Widget _buildCommunityPage() {
    // If SDK is not initialized, show message
    if (!_isInitialized) {
      return const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, size: 64, color: Colors.orange),
            SizedBox(height: 16),
            Text(
              'SDK not initialized',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 8),
            Text(
              'Please initialize the SDK in the Configuration tab',
              textAlign: TextAlign.center,
            ),
          ],
        ),
      );
    }

    // Example of using the embedded widget with custom theme

    var embeddedTheme = OctopusTheme(
      // Different colors for the embedded view
      primaryMain: Colors.lightBlue,
      primaryLowContrast: Colors.lightBlue.withValues(alpha: 0.2),
      primaryHighContrast: Colors.lightBlue.withValues(alpha: 0.4),
      onPrimary: Colors.deepPurple,

      // Smaller font sizes for the embedded view
      fontSizeTitle1: 12, // Smaller than default (26)
      fontSizeTitle2: 18, // Smaller than default (20)
      fontSizeBody1: 15, // Smaller than default (17)
      fontSizeBody2: 14, // Smaller than default (14)
      fontSizeCaption1: 11, // Smaller than default (12)
      fontSizeCaption2: 9, // Smaller than default (10)
      // Custom logo
      logoBase64: logo!,
      themeMode: OctopusThemeMode.light,
    );

    // Deep link from a notification tap. The ValueKey forces a fresh
    // PlatformView creation when a new notification arrives — necessary
    // because UiKitView/AndroidView ignore creationParams changes after
    // initial mount. _pendingCommunityNotification is cleared in the tab
    // bar's onTap when the user leaves this tab, so returning to it shows
    // the community root unless another notification arrived in between.
    final pending = _pendingCommunityNotification;
    final widgetKey = ValueKey<String>(pending?.linkPath ?? 'community-root');

    // Using the OctopusHomeScreen widget
    return OctopusHomeScreen(
      key: widgetKey,
      theme: embeddedTheme,
      navBarPrimaryColor: true,
      showBackButton: false,
      enabled: _isInitialized,
      notification: pending,
      // will be called when an anonymous user (a user on which you did not perform a connectUser) wants to write content
      onNavigateToLogin: () {
        Navigator.of(
          context,
        ).push(MaterialPageRoute(builder: (context) => const LoginPage()));
      },
      // If you have appManagedFields, you need to handle when a user wants to modify his profile
      onModifyUser: (fieldToEdit) {
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => ProfileEditPage(fieldToEdit: fieldToEdit, octopus: octopus),
          ),
        );
      },
      onNavigateToUrl: (url) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('On Navigate to Url: $url')),
        );
        return UrlOpeningStrategy.handledByApp;
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _currentTabIndex == 0 ? _buildConfigPage() : _buildCommunityPage(),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _currentTabIndex,
        onTap: (index) {
          setState(() {
            _currentTabIndex = index;
            // Leaving the Community tab discards any pending deep link so
            // that returning to it opens the community root, not the last
            // notification's target.
            if (index != _communityTabIndex) {
              _pendingCommunityNotification = null;
            }
          });
        },
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.settings),
            label: 'Configuration',
          ),
          BottomNavigationBarItem(icon: Icon(Icons.people), label: 'Community'),
        ],
      ),
    );
  }
}