octopus_sdk_flutter 1.11.0
octopus_sdk_flutter: ^1.11.0 copied to clipboard
Flutter plugin for Octopus Community SDK
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'),
],
),
);
}
}