octopus_sdk_flutter 1.12.0
octopus_sdk_flutter: ^1.12.0 copied to clipboard
White-label social community SDK for Flutter — embed a moderated community in your app.
import 'dart:async';
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:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:octopus_sdk_flutter/octopus_sdk_flutter.dart';
import 'app_log.dart';
import 'app_state.dart';
import 'branding.dart';
import 'community/community_screen.dart';
import 'config/config_screen.dart';
import 'debug/debug_log.dart';
import 'debug/debug_tab.dart';
import 'home/home_screen.dart';
import 'octopus_demo_config.dart';
import 'scenarios/scenarios_screen.dart';
import 'settings/settings_screen.dart';
import 'widgets/production_warning_banner.dart';
/// MethodChannel the iOS AppDelegate uses to forward the APNs token + taps.
const MethodChannel _pushChannel = MethodChannel(
'octopus_sdk_flutter_example/push',
);
/// Lets push handlers act outside a widget build context (cold-start handling).
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
/// Renders incoming Octopus pushes as system notifications. Octopus sends
/// **data-only** FCM messages, which Android does not auto-display, and in a
/// Flutter app the `firebase_messaging` plugin (not the native Octopus
/// `MessagingService`) receives the message — so the host app must build and
/// show the notification itself. Consumers wiring push must do the same.
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
const AndroidNotificationChannel _octopusChannel = AndroidNotificationChannel(
'octopus-sdk',
'Octopus Community',
importance: Importance.high,
);
/// Initialises the local-notifications plugin in the current isolate (the app
/// isolate and the FCM background isolate each need their own init). Pass
/// [onTap] in the app isolate to route taps; the background isolate omits it
/// (a tap there relaunches the app and is delivered via launch details).
Future<void> _initLocalNotifications({
DidReceiveNotificationResponseCallback? onTap,
}) async {
await _localNotifications.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('ic_stat_notification'),
iOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: onTap,
);
await _localNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(_octopusChannel);
}
/// Shows a system notification for an Octopus push payload (no-op for
/// non-Octopus payloads). Pure parse + display, so it is safe to call from the
/// FCM background isolate. The notification carries the Octopus keys as its
/// `payload` so a tap can reconstruct and deep-link them.
Future<void> showOctopusPushNotification(Map<String, Object?> payload) async {
if (!OctopusSDK.isOctopusNotification(payload)) return;
final n = OctopusSDK.getOctopusNotification(payload);
if (n == null) return;
await _localNotifications.show(
n.linkPath.hashCode,
n.title.isEmpty ? 'Octopus Community' : n.title,
n.body.isEmpty ? null : n.body,
NotificationDetails(
android: AndroidNotificationDetails(
_octopusChannel.id,
_octopusChannel.name,
importance: Importance.high,
priority: Priority.high,
),
),
payload: jsonEncode(n.rawPayload),
);
}
/// Flattens an FCM message to the flat map the Octopus parser expects, folding
/// any `notification` title/body into the data keys.
Map<String, Object?> _flatFcmData(RemoteMessage message) {
final data = <String, Object?>{...message.data};
final n = message.notification;
if (n?.title != null) data.putIfAbsent('title', () => n!.title as Object);
if (n?.body != null) data.putIfAbsent('body', () => n!.body as Object);
return data;
}
/// FCM background-isolate handler — must be top-level and `vm:entry-point`.
/// Displays the Octopus notification when the app is backgrounded/terminated.
@pragma('vm:entry-point')
Future<void> octopusFcmBackgroundHandler(RemoteMessage message) async {
await _initLocalNotifications();
await showOctopusPushNotification(_flatFcmData(message));
}
/// Public showcase entrypoint. The QA/debug build uses
/// `lib/debug/main_debug.dart`, which installs the Debug console then calls
/// [runOctopusDemo].
Future<void> main() => runOctopusDemo();
/// Boots the sample app. Shared by the public and debug entrypoints.
Future<void> runOctopusDemo() async {
WidgetsFlutterBinding.ensureInitialized();
// Start the persistent debug recorder before the SDK can emit anything, so
// the Debug tab (a public bottom-nav tab) and the Events scenario capture the
// whole session. Both `OctopusSDK.eventStream` and the typed `events` stream
// are non-replaying broadcasts — a late subscriber misses prior emissions —
// so we subscribe once, here, at launch, and route the sample's API-call log
// into the same recorder. Idempotent with the debug entrypoint's
// `installDebugConsole()` (which additionally installs the internal-only
// Settings → "Open debug console" sheet).
debugLog.start();
demoLog = debugLog;
// Firebase Messaging powers the Android push path. iOS uses the native APNs
// wiring in AppDelegate.swift; Firebase is not initialized on iOS here (no
// GoogleService-Info.plist bundled). The Android init is best-effort: without
// a google-services.json it throws, which we swallow so the app still runs.
if (Platform.isAndroid) {
try {
await Firebase.initializeApp();
// Render Octopus pushes that arrive while the app is backgrounded /
// terminated (data-only FCM → not auto-displayed by the system).
FirebaseMessaging.onBackgroundMessage(octopusFcmBackgroundHandler);
} catch (e) {
debugPrint('[Push] Firebase.initializeApp failed: $e');
}
}
runApp(const OctopusDemoApp());
}
/// Root widget: owns [AppState], wires push notifications, and chooses between
/// the Config screen and the main bottom-nav shell.
class OctopusDemoApp extends StatefulWidget {
const OctopusDemoApp({super.key});
@override
State<OctopusDemoApp> createState() => _OctopusDemoAppState();
}
class _OctopusDemoAppState extends State<OctopusDemoApp> {
late final AppState _app;
/// Latest push token (FCM on Android, APNs on iOS); (re)registered with the
/// SDK once it is initialised.
String? _pushToken;
StreamSubscription<bool>? _pushInitSub;
StreamSubscription<OctopusConnectionState>? _pushConnSub;
@override
void initState() {
super.initState();
_app = AppState()..addListener(_onAppChanged);
_app.bootstrap();
_setupPush();
}
void _onAppChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_pushInitSub?.cancel();
_pushConnSub?.cancel();
_app.removeListener(_onAppChanged);
_app.dispose();
super.dispose();
}
// ── Push notifications ────────────────────────────────────────────────────
Future<void> _setupPush() async {
// Both platforms: (re)register the push token on every init signal (incl.
// switchCommunity re-init). Registration only sticks once the SDK is
// initialised — see [_registerPushTokenIfReady].
_pushInitSub = OctopusSDK.isInitialisedFlow.listen((initialised) {
if (initialised) _registerPushTokenIfReady();
});
// Also re-register when the connection changes (e.g. guest → logged-in
// user): the token must be tied to the currently-connected user so the
// backend routes that user's notifications to this device.
_pushConnSub = OctopusSDK.connectionState.listen(
(_) => _registerPushTokenIfReady(),
);
if (Platform.isIOS) {
_pushChannel.setMethodCallHandler(_onPushChannelCall);
await _drainInitialIosNotification();
} else if (Platform.isAndroid) {
await _setupFirebaseMessaging();
}
}
Future<void> _setupFirebaseMessaging() async {
final messaging = FirebaseMessaging.instance;
try {
await messaging.requestPermission();
// Keep the latest FCM token and (re)register it with the SDK only once
// the SDK is initialised. `registerPushNotificationToken` reaches the
// native `registerNotificationsToken`, which requires an initialised SDK
// (it throws if `sdkScope` isn't set yet). Push setup runs at startup —
// before the Config screen's "Start" initialises the SDK — so registering
// here unconditionally silently drops the token and delivery never
// starts. Register on every init signal (incl. switchCommunity re-init)
// and on token refresh instead.
_pushToken = await messaging.getToken();
messaging.onTokenRefresh.listen((token) {
_pushToken = token;
_registerPushTokenIfReady();
});
_registerPushTokenIfReady();
// Display + tap routing. Octopus FCM messages are data-only, so the host
// builds the system notification itself ([showOctopusPushNotification]);
// tapping it deep-links via [_onNotifTap].
await _initLocalNotifications(onTap: _onNotifTap);
FirebaseMessaging.onMessage.listen(
(m) => showOctopusPushNotification(_payloadFromFcm(m)),
);
// A notification-block FCM tapped from background routes straight through.
FirebaseMessaging.onMessageOpenedApp.listen(
(m) => _handleNotification(_payloadFromFcm(m)),
);
final initial = await messaging.getInitialMessage();
if (initial != null) _handleNotification(_payloadFromFcm(initial));
// Cold start from tapping a notification this app rendered itself.
final launch = await _localNotifications
.getNotificationAppLaunchDetails();
if (launch?.didNotificationLaunchApp ?? false) {
final response = launch!.notificationResponse;
if (response != null) _onNotifTap(response);
}
} catch (e) {
debugPrint('[Push] Firebase messaging setup failed: $e');
}
}
/// Routes a tapped local notification to the deep-link handler, decoding the
/// Octopus keys stored in the notification payload.
void _onNotifTap(NotificationResponse response) {
final raw = response.payload;
if (raw == null || raw.isEmpty) return;
try {
final decoded = jsonDecode(raw);
if (decoded is Map) _handleNotification(decoded);
} catch (e) {
debugPrint('[Push] could not decode tapped notification payload: $e');
}
}
/// Registers the latest FCM token with the SDK, but only when the SDK is
/// initialised — a pre-init call throws natively and silently drops the
/// token, so push delivery never starts. Idempotent: safe to call on each
/// init / token-refresh signal.
///
/// Reads `OctopusSDK.isInitialised` directly rather than `_app.isInitialised`
/// to avoid a listener-ordering race: `_app.isInitialised` is updated by
/// AppState's own subscription to `isInitialisedFlow`, and Dart does not
/// guarantee firing order between two listeners on the same stream. The
/// static getter reflects the value set **before** subscribers are notified
/// (see `octopus_sdk.dart:151`), so reading it here is race-free.
void _registerPushTokenIfReady() {
final token = _pushToken;
if (token == null || token.isEmpty || !OctopusSDK.isInitialised) return;
_app.octopus
.registerPushNotificationToken(token)
.then((_) => debugPrint('[Push] push token registered with the SDK'))
.catchError(
(e) => debugPrint('[Push] registerPushNotificationToken failed: $e'),
);
}
Future<void> _drainInitialIosNotification() async {
try {
final raw = await _pushChannel.invokeMethod('getInitialNotification');
if (raw is Map) _handleNotification(raw);
} catch (e) {
debugPrint('[Push] getInitialNotification failed: $e');
}
}
Future<dynamic> _onPushChannelCall(MethodCall call) async {
switch (call.method) {
case 'apnsToken':
final token = call.arguments as String?;
if (token != null && token.isNotEmpty) {
// Same init-gating as Android: store and (re)register once the SDK is
// initialised, so an APNs token arriving before the Config "Start"
// isn't dropped by the native pre-init guard.
_pushToken = token;
_registerPushTokenIfReady();
}
case 'notificationTapped':
final raw = call.arguments;
if (raw is Map) _handleNotification(raw);
}
return null;
}
/// Merges an FCM `RemoteMessage` into the flat map shape the SDK parses,
/// folding `notification.title`/`body` into the `data` keys.
Map<String, Object?> _payloadFromFcm(RemoteMessage message) {
final payload = <String, Object?>{...message.data};
final notification = message.notification;
if (notification != null) {
final title = notification.title;
final body = notification.body;
if (title != null) payload.putIfAbsent('title', () => title);
if (body != null) payload.putIfAbsent('body', () => body);
}
return payload;
}
/// Deep-links a tapped Octopus notification into the Community tab.
void _handleNotification(Map payload) {
if (!OctopusSDK.isOctopusNotification(payload)) return;
final notification = OctopusSDK.getOctopusNotification(payload);
if (notification != null) _app.requestCommunityTab(notification);
}
// ── Build ───────────────────────────────────────────────────────────────
ThemeMode get _themeMode => switch (_app.config?.theme) {
AppThemeChoice.light => ThemeMode.light,
AppThemeChoice.dark => ThemeMode.dark,
AppThemeChoice.system || null => ThemeMode.system,
};
@override
Widget build(BuildContext context) {
return AppScope(
state: _app,
child: MaterialApp(
navigatorKey: navigatorKey,
title: 'Octopus SDK Sample',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(Brightness.light),
darkTheme: buildAppTheme(Brightness.dark),
themeMode: _themeMode,
// Pin the production/client-env warning above every route. The banner
// consumes the top inset, so the routed content drops it to avoid a
// double status-bar gap; when the banner is hidden the route keeps its
// normal padding.
builder: (context, child) => Column(
children: [
const ProductionWarningBanner(),
Expanded(
child: octopusIsProdServer
? MediaQuery.removePadding(
context: context,
removeTop: true,
child: child!,
)
: child!,
),
],
),
// While bootstrap restores a persisted config, show a splash so a saved
// session doesn't flash the Config screen before auto-starting back in.
home: _app.restoringConfig
? const Scaffold(body: Center(child: CircularProgressIndicator()))
: (_app.config == null
? const ConfigScreen()
: MainScreen(app: _app)),
),
);
}
}
/// Main bottom-navigation shell (Home / Scenarios / Community / Settings).
class MainScreen extends StatefulWidget {
final AppState app;
const MainScreen({super.key, required this.app});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
static const int _communityIndex = 2;
static const int _debugIndex = 4;
// Land directly on Community when there's a pending deep link from a push.
// A cold-start tap on a notification calls `requestCommunityTab` BEFORE this
// screen mounts, so the `navEpoch` diff in `_onAppChanged` can't trip — the
// bump already happened by the time `_seenNavEpoch` is initialised below.
// Read the pending notification synchronously here to honour the deep link.
late int _index = widget.app.pendingCommunityNotification != null
? _communityIndex
: 0;
late int _seenNavEpoch = widget.app.navEpoch;
static const _titles = [
'Home',
'Scenarios',
'Community',
'Settings',
'Debug',
];
@override
void initState() {
super.initState();
widget.app.addListener(_onAppChanged);
}
void _onAppChanged() {
// A push tap bumps navEpoch — jump to the Community tab when it changes.
if (widget.app.navEpoch != _seenNavEpoch) {
_seenNavEpoch = widget.app.navEpoch;
if (mounted) {
// Pop any pushed routes (Modal / Fullscreen / Sheet scenarios, login
// page, profile editor, …) so the bottom-nav shell — and the Community
// tab now hosting the deep link — actually becomes visible. Without
// this, `_index = _communityIndex` still updates the shell but the
// user sees nothing because the pushed route covers it. The pushed
// mode/scenario is dismissed by design: a push always wins routing.
Navigator.maybeOf(
context,
rootNavigator: true,
)?.popUntil((route) => route.isFirst);
setState(() => _index = _communityIndex);
}
}
}
@override
void dispose() {
widget.app.removeListener(_onAppChanged);
super.dispose();
}
void _onTap(int index) {
setState(() => _index = index);
// Leaving Community discards a consumed deep link so it isn't reopened.
if (index != _communityIndex) {
widget.app.clearPendingNotification();
}
}
@override
Widget build(BuildContext context) {
final body = const [
HomeTab(),
ScenariosTab(),
CommunityTab(),
SettingsTab(),
DebugTab(),
][_index];
// The Community tab embeds the SDK with its own native top bar
// (`OctopusHomeScreen` on both platforms) — drop the Flutter shell
// `AppBar` to avoid stacking. The other tabs keep their host `AppBar`.
//
// We could use `OctopusHomeContent` on Android (no-navbar variant) and
// re-show the Flutter `AppBar` so the title matches the other tabs, but
// iOS doesn't yet have an `OctopusHomeContent` equivalent
// (octopus-sdk-swift-private#275). Keeping both platforms on
// `OctopusHomeScreen` aligns them; the non-embedded integration modes
// (Modal / Fullscreen / Sheet) are demonstrated by their respective
// scenarios in the Scenarios tab — each gives the SDK a chrome-clean
// surface without embedding.
//
// The Debug tab carries its own `Scaffold` + `AppBar` (Copy / Clear
// actions) — drop the shell `AppBar` here too to avoid double headers.
final isCommunityTab = _index == _communityIndex;
final isDebugTab = _index == _debugIndex;
final hasOwnAppBar = isCommunityTab || isDebugTab;
return Scaffold(
appBar: hasOwnAppBar ? null : AppBar(title: Text(_titles[_index])),
body: SafeArea(child: body),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: _onTap,
type: BottomNavigationBarType.fixed,
items: [
BottomNavigationBarItem(
icon: Semantics(
identifier: 'home-tab',
child: const Icon(Icons.home),
),
label: 'Home',
),
BottomNavigationBarItem(
icon: Semantics(
identifier: 'scenarios-tab',
child: const Icon(Icons.science),
),
label: 'Scenarios',
),
BottomNavigationBarItem(
icon: Semantics(
identifier: 'community-tab',
child: const Icon(Icons.people),
),
label: 'Community',
),
BottomNavigationBarItem(
icon: Semantics(
identifier: 'settings-tab',
child: const Icon(Icons.settings),
),
label: 'Settings',
),
BottomNavigationBarItem(
icon: Semantics(
identifier: 'debug-tab',
child: const Icon(Icons.bug_report),
),
label: 'Debug',
),
],
),
);
}
}