Skip to main content
The example below demonstrates a complete LearnInkWebView widget for Flutter. It calls your own backend to fetch a sign-in token — your backend is responsible for calling the LearnInk Identify API securely. Before using this, replace the following constants at the top of the file:
  • YOUR_BACKEND_AUTH_URL — your backend endpoint that calls the LearnInk Identify API and returns a token
  • ORG_ID — your LearnInk organisation ID
Dependencies required:
dependencies:
  webview_flutter: ^4.0.0
  http: ^1.0.0
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:http/http.dart' as http;

// Configuration constants
const String YOUR_BACKEND_AUTH_URL = 'https://your-api.com/auth/learnink';
const String WEBVIEW_BASE_URL = 'https://m.learn.ink';
const String ORG_ID = 'acme';
const int REQUEST_TIMEOUT = 5000;

class MessageTypes {
  static const String CLOSE = 'CLOSE';
  static const String SESSION_EXPIRED = 'SESSION_EXPIRED';
}

class LearnInkWebView extends StatefulWidget {
  final String path;
  final VoidCallback onClose;
  final String userId;

  const LearnInkWebView({
    Key? key,
    required this.path,
    required this.onClose,
    required this.userId,
  }) : super(key: key);

  @override
  State<LearnInkWebView> createState() => _LearnInkWebViewState();
}

class _LearnInkWebViewState extends State<LearnInkWebView> {
  String? _webViewUrl;
  bool _loading = true;
  late final WebViewController _webViewController;

  @override
  void initState() {
    super.initState();
    _initializeWebViewController();
    _authenticateUser();
  }

  void _initializeWebViewController() {
    _webViewController = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setNavigationDelegate(
        NavigationDelegate(
          onPageStarted: (String url) => setState(() => _loading = true),
          onPageFinished: (String url) => setState(() => _loading = false),
        ),
      )
      ..addJavaScriptChannel(
        'LearnInkWebView',
        onMessageReceived: (JavaScriptMessage message) {
          _handleMessage(message.message);
        },
      );
  }

  String _buildWebViewUrl({String? token}) {
    final params = {
      'webview': 'true',
      if (token != null) 'token': token,
    };
    final queryString = Uri(queryParameters: params).query;
    return '$WEBVIEW_BASE_URL/$ORG_ID/${widget.path}?$queryString';
  }

  Future<void> _authenticateUser() async {
    setState(() => _loading = true);
    try {
      final token = await _fetchAuthToken();
      final url = _buildWebViewUrl(token: token);
      await _webViewController.loadRequest(Uri.parse(url));
      setState(() => _webViewUrl = url);
    } catch (error) {
      debugPrint('Error authenticating user: $error');
      // Fall back to loading without a token if the user has an existing session
      final url = _buildWebViewUrl();
      await _webViewController.loadRequest(Uri.parse(url));
      setState(() => _webViewUrl = url);
    } finally {
      setState(() => _loading = false);
    }
  }

  Future<String> _fetchAuthToken() async {
    final response = await http
        .post(
          Uri.parse(YOUR_BACKEND_AUTH_URL),
          headers: {
            'Content-Type': 'application/json',
            // Add your own auth headers here, e.g.:
            // 'Authorization': 'Bearer $yourUserSessionToken',
          },
          body: jsonEncode({'id': widget.userId}),
        )
        .timeout(const Duration(milliseconds: REQUEST_TIMEOUT));

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      return data['token'];
    } else {
      final error = jsonDecode(response.body);
      throw Exception(error['message'] ?? 'Failed to authenticate');
    }
  }

  void _handleMessage(String messageData) {
    try {
      final message = jsonDecode(messageData);
      switch (message['type']) {
        case MessageTypes.CLOSE:
          widget.onClose();
          break;
        case MessageTypes.SESSION_EXPIRED:
          _handleSessionExpired();
          break;
        default:
          debugPrint('Unknown message type: ${message['type']}');
      }
    } catch (error) {
      debugPrint('Error handling message: $error');
    }
  }

  Future<void> _handleSessionExpired() async {
    setState(() => _loading = true);
    try {
      final token = await _fetchAuthToken();
      final url = _buildWebViewUrl(token: token);
      await _webViewController.loadRequest(Uri.parse(url));
      setState(() => _webViewUrl = url);
    } catch (error) {
      debugPrint('Error refreshing session: $error');
      if (mounted) {
        showDialog(
          context: context,
          builder: (context) => AlertDialog(
            title: const Text('Session Refresh Failed'),
            content: const Text('Unable to refresh your session. Please close and try again.'),
            actions: [
              TextButton(
                onPressed: () { Navigator.of(context).pop(); widget.onClose(); },
                child: const Text('Close'),
              ),
              TextButton(
                onPressed: () { Navigator.of(context).pop(); _handleSessionExpired(); },
                child: const Text('Retry'),
              ),
            ],
          ),
        );
      }
    } finally {
      setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_loading || _webViewUrl == null) {
      return const Scaffold(
        body: Center(child: CircularProgressIndicator(color: Color(0xFF007AFF))),
      );
    }
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: [
            WebViewWidget(controller: _webViewController),
            if (_loading)
              Container(
                color: Colors.white,
                child: const Center(
                  child: CircularProgressIndicator(color: Color(0xFF007AFF)),
                ),
              ),
          ],
        ),
      ),
    );
  }
}