rolla_sdk 0.1.12
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),
),
],
),
),
);
}
}