rolla_sdk 0.1.12 copy "rolla_sdk: ^0.1.12" to clipboard
rolla_sdk: ^0.1.12 copied to clipboard

Rolla Health & Fitness SDK for Flutter apps — drop-in health, activity tracking, wearable band pairing, and white-label UI.

example/lib/main.dart

/// Example app for Rolla SDK
///
/// This example app demonstrates launching the Rolla SDK
/// with token-based authentication, matching the iOS demo app pattern.
///
/// To run this example:
/// ```
/// cd rolla-sdk/example
/// flutter run
/// ```
library;

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:rolla_sdk/rolla_sdk.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const RollaSDKLauncherApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Rolla SDK Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2196F3)),
        useMaterial3: true,
      ),
      home: const LauncherScreen(),
    );
  }
}

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

  @override
  State<LauncherScreen> createState() => _LauncherScreenState();
}

class _LauncherScreenState extends State<LauncherScreen> {
  // Token state
  String? _accessToken;
  String? _userId;
  String _tokenType = ''; // 'partner' or 'user'
  bool _isLoading = false;
  String _statusMessage = 'Choose a token type to authenticate';
  Color _statusColor = Colors.grey;

  // Partner credentials (same as iOS demo app)
  static const String _apiBaseUrl = 'https://ross-rnd.rolla.cloud';
  static const String _partnerId = 'rollarnd_3a44409a5cb35899';
  static const String _partnerSecret = 'RollaRnd2025!';

  // User credentials controllers
  final _emailController = TextEditingController(
    // NOTE: For development purposes you can set this to your own email
    // so everytime you hot reload you don't have to type it again.
    // text: 'your-email@example.com',
  );
  final _passwordController = TextEditingController(
    // SEE NOTE ABOVE EMAIL.
    // text: 'emi.cazorla@hotmail.com',
  );

  bool get _hasToken => _accessToken != null;

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey.shade100,
      appBar: AppBar(
        title: const Text('Rolla SDK Demo'),
        centerTitle: true,
        elevation: 0,
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(24.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              // Steps Card
              Card(
                elevation: 2,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(16),
                ),
                child: Padding(
                  padding: const EdgeInsets.all(20.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      _buildStep(
                        number: '1',
                        title: 'Fetch Token',
                        subtitle: _hasToken
                            ? '$_tokenType token: ${_accessToken!.substring(0, 15)}...'
                            : 'Get partner or user token',
                        isComplete: _hasToken,
                      ),
                      const SizedBox(height: 16),
                      _buildStep(
                        number: '2',
                        title: 'Launch Rolla',
                        subtitle: 'Open the Rolla experience',
                        isComplete: false,
                      ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 24),

              // Partner Token Button
              ElevatedButton.icon(
                onPressed: _isLoading ? null : _fetchPartnerToken,
                icon: const Icon(Icons.business, size: 20),
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.blue,
                  foregroundColor: Colors.white,
                  disabledBackgroundColor: Colors.blue.shade200,
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
                label: _isLoading && _tokenType == 'Partner'
                    ? const SizedBox(
                        height: 24,
                        width: 24,
                        child: CircularProgressIndicator(
                          color: Colors.white,
                          strokeWidth: 2,
                        ),
                      )
                    : const Text(
                        'FETCH PARTNER TOKEN',
                        style: TextStyle(
                          fontSize: 15,
                          fontWeight: FontWeight.w600,
                        ),
                      ),
              ),
              const SizedBox(height: 12),

              // Divider with "OR"
              Row(
                children: [
                  Expanded(child: Divider(color: Colors.grey.shade400)),
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16),
                    child: Text(
                      'OR',
                      style: TextStyle(
                        color: Colors.grey.shade600,
                        fontWeight: FontWeight.w500,
                      ),
                    ),
                  ),
                  Expanded(child: Divider(color: Colors.grey.shade400)),
                ],
              ),
              const SizedBox(height: 12),

              // User Login Card
              Card(
                elevation: 1,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      Text(
                        'User Login',
                        style: TextStyle(
                          fontSize: 14,
                          fontWeight: FontWeight.bold,
                          color: Colors.grey.shade800,
                        ),
                      ),
                      const SizedBox(height: 12),
                      TextField(
                        controller: _emailController,
                        decoration: InputDecoration(
                          labelText: 'Email',
                          hintText: 'Enter your email',
                          border: OutlineInputBorder(
                            borderRadius: BorderRadius.circular(8),
                          ),
                          contentPadding: const EdgeInsets.symmetric(
                            horizontal: 12,
                            vertical: 12,
                          ),
                          isDense: true,
                        ),
                        keyboardType: TextInputType.emailAddress,
                      ),
                      const SizedBox(height: 12),
                      TextField(
                        controller: _passwordController,
                        decoration: InputDecoration(
                          labelText: 'Password',
                          hintText: 'Enter your password',
                          border: OutlineInputBorder(
                            borderRadius: BorderRadius.circular(8),
                          ),
                          contentPadding: const EdgeInsets.symmetric(
                            horizontal: 12,
                            vertical: 12,
                          ),
                          isDense: true,
                        ),
                        obscureText: true,
                      ),
                      const SizedBox(height: 16),
                      ElevatedButton.icon(
                        onPressed: _isLoading ? null : _fetchUserToken,
                        icon: const Icon(Icons.person, size: 20),
                        style: ElevatedButton.styleFrom(
                          backgroundColor: Colors.purple,
                          foregroundColor: Colors.white,
                          disabledBackgroundColor: Colors.purple.shade200,
                          padding: const EdgeInsets.symmetric(vertical: 14),
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(12),
                          ),
                        ),
                        label: _isLoading && _tokenType == 'User'
                            ? const SizedBox(
                                height: 22,
                                width: 22,
                                child: CircularProgressIndicator(
                                  color: Colors.white,
                                  strokeWidth: 2,
                                ),
                              )
                            : const Text(
                                'FETCH USER TOKEN',
                                style: TextStyle(
                                  fontSize: 15,
                                  fontWeight: FontWeight.w600,
                                ),
                              ),
                      ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 24),

              // Launch SDK Button
              ElevatedButton(
                onPressed: _hasToken ? _launchSDK : null,
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.green,
                  foregroundColor: Colors.white,
                  disabledBackgroundColor: Colors.grey.shade300,
                  disabledForegroundColor: Colors.grey.shade500,
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
                child: const Text(
                  'LAUNCH SDK',
                  style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
                ),
              ),
              const SizedBox(height: 16),

              // Status Message
              if (_statusMessage.isNotEmpty)
                Container(
                  padding: const EdgeInsets.all(12),
                  decoration: BoxDecoration(
                    color: _statusColor.withValues(alpha: 0.1),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Text(
                    _statusMessage,
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontSize: 14,
                      color: _statusColor,
                      fontWeight: FontWeight.w500,
                    ),
                  ),
                ),
              const SizedBox(height: 24),

              // Features Card
              Card(
                elevation: 1,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'SDK Modules Included:',
                        style: TextStyle(
                          fontSize: 14,
                          fontWeight: FontWeight.bold,
                          color: Colors.grey.shade800,
                        ),
                      ),
                      const SizedBox(height: 12),
                      Wrap(
                        spacing: 8,
                        runSpacing: 8,
                        children: [
                          _buildFeatureChip('Metrics'),
                          _buildFeatureChip('Weight'),
                          _buildFeatureChip('Goals'),
                          _buildFeatureChip('Activities'),
                          _buildFeatureChip('Band Sync'),
                          _buildFeatureChip('Insights'),
                          _buildFeatureChip('Profile'),
                          _buildFeatureChip('+12 more'),
                        ],
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildStep({
    required String number,
    required String title,
    required String subtitle,
    required bool isComplete,
  }) {
    return Row(
      children: [
        Container(
          width: 32,
          height: 32,
          decoration: BoxDecoration(
            color: isComplete ? Colors.green : Colors.grey.shade300,
            borderRadius: BorderRadius.circular(16),
          ),
          child: Center(
            child: isComplete
                ? const Icon(Icons.check, color: Colors.white, size: 18)
                : Text(
                    number,
                    style: const TextStyle(
                      color: Colors.white,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
          ),
        ),
        const SizedBox(width: 16),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                title,
                style: const TextStyle(
                  fontSize: 16,
                  fontWeight: FontWeight.w600,
                ),
              ),
              Text(
                subtitle,
                style: TextStyle(
                  fontSize: 13,
                  color: Colors.grey.shade600,
                ),
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
              ),
            ],
          ),
        ),
        if (isComplete)
          Text(
            'Done',
            style: TextStyle(
              color: Colors.green.shade700,
              fontWeight: FontWeight.bold,
            ),
          ),
      ],
    );
  }

  Widget _buildFeatureChip(String label) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
      decoration: BoxDecoration(
        color: Colors.blue.shade50,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: Colors.blue.shade200),
      ),
      child: Text(
        label,
        style: TextStyle(
          fontSize: 12,
          color: Colors.blue.shade700,
        ),
      ),
    );
  }

  /// Fetch partner token using client_credentials flow
  Future<void> _fetchPartnerToken() async {
    setState(() {
      _isLoading = true;
      _tokenType = 'Partner';
      _statusMessage = 'Fetching partner token...';
      _statusColor = Colors.grey;
    });

    try {
      final dio = Dio(
        BaseOptions(
          baseUrl: _apiBaseUrl,
          connectTimeout: const Duration(seconds: 30),
          receiveTimeout: const Duration(seconds: 30),
        ),
      );

      debugPrint(
        '[Example] Fetching partner token from $_apiBaseUrl/partners/v1/token',
      );

      final response = await dio.post<dynamic>(
        '/partners/v1/token',
        data: {
          'grant_type': 'client_credentials',
          'partner_id': _partnerId,
          'partner_secret': _partnerSecret,
        },
        options: Options(
          contentType: Headers.formUrlEncodedContentType,
          headers: {
            'User-Agent': 'Dart/3.0 (dart:io)',
            'Accept': '*/*',
          },
        ),
      );

      debugPrint('[Example] Response status: ${response.statusCode}');
      debugPrint('[Example] Response data: ${response.data}');

      final data = response.data as Map<String, dynamic>;
      final accessToken = data['access_token'] as String?;

      if (accessToken != null && accessToken.isNotEmpty) {
        String? userId;
        try {
          final parts = accessToken.split('.');
          if (parts.length == 3) {
            final payload = utf8.decode(
              base64Url.decode(base64Url.normalize(parts[1])),
            );
            final payloadMap = jsonDecode(payload) as Map<String, dynamic>;
            userId =
                payloadMap['user_id'] as String? ?? payloadMap['sub'] as String? ?? payloadMap['partner_id'] as String?;
            debugPrint('[Example] JWT payload: $payloadMap');
          }
        } catch (e) {
          debugPrint('[Example] JWT decode error: $e');
          userId = _partnerId;
        }

        setState(() {
          _accessToken = accessToken;
          _userId = userId ?? _partnerId;
          _isLoading = false;
          _statusMessage = 'Partner token ready! Tap LAUNCH SDK';
          _statusColor = Colors.green;
        });

        debugPrint(
          '[Example] Partner token obtained: ${accessToken.substring(0, 30)}...',
        );
      } else {
        throw Exception(
          data['error'] ?? data['message'] ?? 'No access token in response',
        );
      }
    } on DioException catch (e) {
      _handleDioError(e);
    } catch (e) {
      setState(() {
        _isLoading = false;
        _statusMessage = 'Error: ${e.toString().replaceAll('Exception: ', '')}';
        _statusColor = Colors.red;
      });
      debugPrint('[Example] Partner token fetch error: $e');
    }
  }

  /// Fetch user token using email/password login
  Future<void> _fetchUserToken() async {
    final email = _emailController.text.trim();
    final password = _passwordController.text;

    if (email.isEmpty || password.isEmpty) {
      setState(() {
        _statusMessage = 'Please enter email and password';
        _statusColor = Colors.orange;
      });
      return;
    }

    setState(() {
      _isLoading = true;
      _tokenType = 'User';
      _statusMessage = 'Logging in as $email...';
      _statusColor = Colors.grey;
    });

    try {
      final dio = Dio(
        BaseOptions(
          baseUrl: _apiBaseUrl,
          connectTimeout: const Duration(seconds: 30),
          receiveTimeout: const Duration(seconds: 30),
        ),
      );

      debugPrint('[Example] Logging in user: $email');

      // User login endpoint requires Partner-ID header and form-urlencoded body
      final response = await dio.post<dynamic>(
        '/api/login',
        data: 'email=${Uri.encodeComponent(email)}&password=${Uri.encodeComponent(password)}',
        options: Options(
          headers: {
            'Partner-ID': _partnerId,
            'User-Agent': 'Dart/3.0 (dart:io)',
            'Accept': '*/*',
          },
          contentType: Headers.formUrlEncodedContentType,
        ),
      );

      debugPrint('[Example] Response status: ${response.statusCode}');
      debugPrint('[Example] Response data: ${response.data}');

      final data = response.data as Map<String, dynamic>;

      if (data['success'] == true) {
        // User login returns tokens in a nested 'tokens' object
        final tokensData = data['tokens'] as Map<String, dynamic>?;
        final accessToken = tokensData?['access_token'] as String? ?? data['access_token'] as String?;

        if (accessToken == null || accessToken.isEmpty) {
          throw Exception('No access token in response');
        }

        // Extract user ID from JWT payload
        String? userId;
        try {
          final parts = accessToken.split('.');
          if (parts.length == 3) {
            final payload = utf8.decode(
              base64Url.decode(base64Url.normalize(parts[1])),
            );
            final payloadMap = jsonDecode(payload) as Map<String, dynamic>;
            userId = payloadMap['sub'] as String? ?? payloadMap['user_id'] as String? ?? email;
            debugPrint('[Example] JWT payload: $payloadMap');
          }
        } catch (e) {
          debugPrint('[Example] JWT decode error: $e');
          userId = email;
        }

        setState(() {
          _accessToken = accessToken;
          _userId = userId ?? email;
          _isLoading = false;
          _statusMessage = 'User token ready! Logged in as $email';
          _statusColor = Colors.green;
        });

        debugPrint(
          '[Example] User token obtained: ${accessToken.substring(0, 30)}...',
        );
        debugPrint('[Example] User ID: $userId');
      } else {
        throw Exception(data['reason'] ?? 'Login failed');
      }
    } on DioException catch (e) {
      _handleDioError(e);
    } catch (e) {
      setState(() {
        _isLoading = false;
        _statusMessage = 'Error: ${e.toString().replaceAll('Exception: ', '')}';
        _statusColor = Colors.red;
      });
      debugPrint('[Example] User token fetch error: $e');
    }
  }

  void _handleDioError(DioException e) {
    debugPrint('[Example] DioException: ${e.type}');
    debugPrint('[Example] Response: ${e.response?.data}');

    String errorMessage = 'Network error';
    if (e.response?.data is Map) {
      final data = e.response!.data as Map<String, dynamic>;
      errorMessage =
          data['error'] as String? ??
          data['message'] as String? ??
          data['reason'] as String? ??
          'Request failed (${e.response?.statusCode})';
    } else if (e.response != null) {
      errorMessage = 'Request failed: ${e.response?.statusCode}';
    }

    setState(() {
      _isLoading = false;
      _statusMessage = 'Error: $errorMessage';
      _statusColor = Colors.red;
    });
  }

  Future<void> _launchSDK() async {
    if (_accessToken == null) return;

    setState(() {
      _statusMessage = 'Launching SDK...';
      _statusColor = Colors.blue;
    });

    Navigator.of(context).pushReplacement(
      MaterialPageRoute(
        builder: (context) => _SDKContainer(
          accessToken: _accessToken!,
          userId: _userId!,
          partnerId: _partnerId,
        ),
      ),
    );
  }
}

/// Container that initializes and runs the SDK
class _SDKContainer extends StatefulWidget {
  final String accessToken;
  final String userId;
  final String partnerId;

  const _SDKContainer({
    required this.accessToken,
    required this.userId,
    required this.partnerId,
  });

  @override
  State<_SDKContainer> createState() => _SDKContainerState();
}

class _SDKContainerState extends State<_SDKContainer> {
  bool _initialized = false;
  String? _error;

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

  Future<void> _initializeSDK() async {
    try {
      debugPrint('[Example] Starting SDK initialization with token...');

      // Initialize SDK with the token (this is the ONLY init call needed)
      await RollaSDK.initializeWithToken(
        accessToken: widget.accessToken,
        userId: widget.userId,
        partnerId: widget.partnerId,
        environment: RollaEnvironment.rnd,
        onTokenExpired: () async {
          // In a real app, you would refresh the token here
          debugPrint('[Example] Token expired - would refresh in production');
          return null;
        },
      );

      debugPrint('[Example] SDK initialization completed!');

      if (mounted) {
        setState(() {
          _initialized = true;
        });
      }
    } catch (e, stack) {
      debugPrint('[Example] SDK initialization error: $e');
      debugPrint('[Example] Stack: $stack');
      if (mounted) {
        setState(() {
          _error = e.toString();
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_error != null) {
      return Scaffold(
        appBar: AppBar(title: const Text('SDK Error')),
        body: Center(
          child: Padding(
            padding: const EdgeInsets.all(24.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.error_outline, size: 64, color: Colors.red),
                const SizedBox(height: 16),
                const Text(
                  'Failed to initialize SDK',
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 8),
                Text(
                  _error!,
                  textAlign: TextAlign.center,
                  style: TextStyle(color: Colors.grey.shade600),
                ),
                const SizedBox(height: 24),
                ElevatedButton(
                  onPressed: () => Navigator.of(context).pushReplacement(
                    MaterialPageRoute(builder: (_) => const LauncherScreen()),
                  ),
                  child: const Text('Go Back'),
                ),
              ],
            ),
          ),
        ),
      );
    }

    if (_initialized) {
      return RollaSdkHome(userId: widget.userId);
    }

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const CircularProgressIndicator(),
            const SizedBox(height: 24),
            const Text(
              'Initializing Rolla SDK...',
              style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
            ),
            const SizedBox(height: 8),
            Text(
              'Loading all modules',
              style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
            ),
          ],
        ),
      ),
    );
  }
}