upscopeio_flutter_sdk 2026.7.5
upscopeio_flutter_sdk: ^2026.7.5 copied to clipboard
Flutter SDK for Upscope cobrowsing — screen sharing, annotations, and remote control
example/lib/main.dart
import 'dart:async';
import 'dart:developer' as dev;
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:upscopeio_flutter_sdk/upscopeio_flutter_sdk.dart';
void main() {
UpscopeMethodChannel.register();
runApp(const UpscopeExampleApp());
}
// ---------------------------------------------------------------------------
// App root
// ---------------------------------------------------------------------------
class UpscopeExampleApp extends StatelessWidget {
const UpscopeExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Upscope SDK Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF5C6BC0)),
useMaterial3: true,
),
home: const DemoScreen(),
);
}
}
// ---------------------------------------------------------------------------
// Top-level screen — owns the event log and coordinates child sections
// ---------------------------------------------------------------------------
class DemoScreen extends StatefulWidget {
const DemoScreen({super.key});
@override
State<DemoScreen> createState() => _DemoScreenState();
}
class _DemoScreenState extends State<DemoScreen> {
static const _maxLogEntries = 20;
final _upscope = Upscope.instance;
final _eventLog = <String>[];
final _subscriptions = <StreamSubscription>[];
StreamSubscription<FullDeviceRequest>? _fullDeviceRequestSub;
StreamSubscription<SessionRequest>? _sessionRequestSub;
@override
void initState() {
super.initState();
_subscribeToEvents();
_subscribeToFullDeviceRequests();
_subscribeToSessionRequests();
}
/// Full-device requests are handled here (not in a child section) because the
/// dialog needs a navigator context that outlives any single section.
void _subscribeToFullDeviceRequests() {
_fullDeviceRequestSub = _upscope.onFullDeviceRequest.listen((request) {
_promptFullDeviceRequest(request);
});
}
Future<void> _promptFullDeviceRequest(FullDeviceRequest request) async {
final agent = request.agentName ?? 'An agent';
final accept = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Full-device sharing requested'),
content: Text(
'$agent is requesting to share your entire device screen. Allow?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Decline'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Allow'),
),
],
),
);
await _upscope.respondToFullDeviceRequest(request.requestId,
accept: accept ?? false);
}
/// Session-start requests are handled here (not in a child section) because
/// the dialog needs a navigator context that outlives any single section.
void _subscribeToSessionRequests() {
_sessionRequestSub = _upscope.onSessionRequest.listen((request) {
_promptSessionRequest(request);
});
}
Future<void> _promptSessionRequest(SessionRequest request) async {
final agent = request.agentName ?? 'An agent';
final accept = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Session requested'),
content: Text(
'$agent is requesting to start a cobrowsing session. Allow?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Deny'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Allow'),
),
],
),
);
await _upscope.respondToSessionRequest(request.requestId,
accept: accept ?? false);
}
void _subscribeToEvents() {
void log(String msg) {
dev.log(msg, name: 'UpscopeDemo');
if (!mounted) return;
setState(() {
_eventLog.insert(0, '[${_timestamp()}] $msg');
if (_eventLog.length > _maxLogEntries) {
_eventLog.removeLast();
}
});
}
_subscriptions.addAll([
_upscope.connectionState.listen(
(s) => log('connectionState → ${s.name}'),
),
_upscope.sessionState.listen((s) => log('sessionState → ${s.name}')),
_upscope.shortId.listen((id) => log('shortId → ${id ?? 'null'}')),
_upscope.lookupCode.listen(
(code) => log('lookupCode → ${code ?? 'null'}'),
),
_upscope.onSessionStarted.listen((id) => log('sessionStarted id=$id')),
_upscope.onSessionEnded.listen(
(reason) => log('sessionEnded reason=${reason.name}'),
),
_upscope.onViewerJoined.listen(
(v) => log('viewerJoined id=${v.id} name=${v.name}'),
),
_upscope.onViewerLeft.listen((id) => log('viewerLeft id=$id')),
_upscope.onViewerCountChanged.listen((n) => log('viewerCount=$n')),
_upscope.onCustomMessageReceived.listen(
(m) => log('customMessage from=${m.viewerId} msg=${m.message}'),
),
_upscope.remoteControlState.listen(
(s) => log('remoteControlState → ${s.name}'),
),
_upscope.fullDeviceSharingState.listen(
(s) => log('fullDeviceSharingState → ${s.name}'),
),
_upscope.onError.listen((e) => log('error ${e.code}: ${e.message}')),
]);
}
@override
void dispose() {
_fullDeviceRequestSub?.cancel();
_sessionRequestSub?.cancel();
for (final sub in _subscriptions) {
sub.cancel();
}
super.dispose();
}
String _timestamp() {
final now = DateTime.now();
return '${now.hour.toString().padLeft(2, '0')}:'
'${now.minute.toString().padLeft(2, '0')}:'
'${now.second.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Upscope SDK Demo'), centerTitle: true),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
InitSection(upscope: _upscope),
const SizedBox(height: 16),
StatusSection(upscope: _upscope),
const SizedBox(height: 16),
ControlSection(upscope: _upscope),
const SizedBox(height: 16),
RemoteControlSection(upscope: _upscope),
const SizedBox(height: 16),
MaskedFieldSection(upscope: _upscope),
const SizedBox(height: 16),
const MaskingZOrderSection(),
const SizedBox(height: 16),
EventLogSection(events: _eventLog),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Init section: API key field + Initialize button
// ---------------------------------------------------------------------------
class InitSection extends StatefulWidget {
final Upscope upscope;
const InitSection({super.key, required this.upscope});
@override
State<InitSection> createState() => _InitSectionState();
}
class _InitSectionState extends State<InitSection> {
final _apiKeyController = TextEditingController();
bool _initialized = false;
bool _initializing = false;
@override
void dispose() {
_apiKeyController.dispose();
super.dispose();
}
Future<void> _initialize() async {
final apiKey = 'cEoQfasmRo';
if (apiKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Enter your Upscope API key first'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() => _initializing = true);
try {
await widget.upscope.initialize(
UpscopeConfiguration(
apiKey: apiKey,
requireAuthorizationForSession: true,
autoConnect: true,
// The example handles session and full-device requests with its own
// dialogs (see _promptSessionRequest / _promptFullDeviceRequest), so
// opt out of the native flows.
customSessionRequestUI: true,
customFullDeviceRequestUI: true,
// iOS full-device sharing is configured in ios/Runner/Info.plist
// (UpscopeAppGroupId / UpscopeBroadcastExtensionBundleId).
),
);
if (mounted) setState(() => _initialized = true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Init failed: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) setState(() => _initializing = false);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return _SectionCard(
title: 'Initialize',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _apiKeyController,
enabled: !_initialized && !_initializing,
decoration: const InputDecoration(
labelText: 'Upscope API Key',
hintText: 'Paste your API key here',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.vpn_key),
),
),
const SizedBox(height: 12),
Row(
children: [
Icon(
_initialized ? Icons.check_circle : Icons.hourglass_empty,
color: _initialized ? cs.tertiary : cs.outline,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_initializing
? 'Initializing…'
: _initialized
? 'SDK initialized'
: 'Not initialized',
),
),
FilledButton(
onPressed: (_initialized || _initializing) ? null : _initialize,
child: const Text('Initialize'),
),
],
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Status section: connection, session, shortId streams
// ---------------------------------------------------------------------------
class StatusSection extends StatefulWidget {
final Upscope upscope;
const StatusSection({super.key, required this.upscope});
@override
State<StatusSection> createState() => _StatusSectionState();
}
class _StatusSectionState extends State<StatusSection> {
final _subs = <StreamSubscription>[];
String? _shortId;
String? _lookupCode;
@override
void initState() {
super.initState();
_subs.addAll([
widget.upscope.shortId.listen((id) {
if (mounted) setState(() => _shortId = id);
}),
widget.upscope.lookupCode.listen((code) {
if (mounted) setState(() => _lookupCode = code);
}),
widget.upscope.connectionState.listen((state) {
if (state == ConnectionState.connected) _fetchValues();
}),
]);
}
Future<void> _fetchValues() async {
final id = await widget.upscope.getShortId();
if (mounted && id != null) setState(() => _shortId = id);
}
@override
void dispose() {
for (final sub in _subs) {
sub.cancel();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return _SectionCard(
title: 'Status',
child: Column(
children: [
_StreamRow<ConnectionState>(
label: 'Connection',
stream: widget.upscope.connectionState,
format: (s) => s.name,
colorFor: _connectionColor,
),
const SizedBox(height: 8),
_StreamRow<SessionState>(
label: 'Session',
stream: widget.upscope.sessionState,
format: (s) => s.name,
colorFor: _sessionColor,
),
const SizedBox(height: 8),
_LabelValue(label: 'Short ID', value: _shortId),
const SizedBox(height: 8),
_LabelValue(label: 'Lookup Code', value: _lookupCode),
],
),
);
}
Color _connectionColor(ConnectionState state, BuildContext ctx) {
final cs = Theme.of(ctx).colorScheme;
return switch (state) {
ConnectionState.connected => cs.tertiary,
ConnectionState.connecting ||
ConnectionState.reconnecting => cs.secondary,
ConnectionState.error => cs.error,
ConnectionState.inactive => cs.outline,
};
}
Color _sessionColor(SessionState state, BuildContext ctx) {
final cs = Theme.of(ctx).colorScheme;
return switch (state) {
SessionState.active => cs.tertiary,
SessionState.pendingRequest => cs.secondary,
SessionState.paused => cs.secondary,
SessionState.ended || SessionState.inactive => cs.outline,
};
}
}
// ---------------------------------------------------------------------------
// Control section: connect/disconnect, lookup code, stop session
// ---------------------------------------------------------------------------
class ControlSection extends StatefulWidget {
final Upscope upscope;
const ControlSection({super.key, required this.upscope});
@override
State<ControlSection> createState() => _ControlSectionState();
}
class _ControlSectionState extends State<ControlSection> {
Future<void> _run(Future<void> Function() action, String label) async {
try {
await action();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label failed: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return _SectionCard(
title: 'Controls',
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton(
onPressed: () => _run(widget.upscope.connect, 'Connect'),
child: const Text('Connect'),
),
OutlinedButton(
onPressed: () => _run(widget.upscope.disconnect, 'Disconnect'),
child: const Text('Disconnect'),
),
OutlinedButton(
onPressed: () => _run(() => widget.upscope.reset(), 'Reset'),
child: const Text('Reset'),
),
OutlinedButton(
onPressed: () => _run(widget.upscope.getLookupCode, 'Lookup code'),
child: const Text('Get Lookup Code'),
),
OutlinedButton(
onPressed: () => _run(widget.upscope.stopSession, 'Stop session'),
child: const Text('Stop Session'),
),
OutlinedButton(
onPressed: () => _run(widget.upscope.requestAgent, 'Request agent'),
child: const Text('Request Agent'),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Remote control section: device-control state + stop
// ---------------------------------------------------------------------------
class RemoteControlSection extends StatelessWidget {
final Upscope upscope;
const RemoteControlSection({super.key, required this.upscope});
Future<void> _run(
BuildContext context,
Future<void> Function() action,
String label,
) async {
try {
await action();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label failed: $e'),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return _SectionCard(
title: 'Remote Control',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_StreamRow<RemoteControlState>(
label: 'Control',
stream: upscope.remoteControlState,
format: (s) => s.name,
colorFor: (s, ctx) => s == RemoteControlState.active
? Theme.of(ctx).colorScheme.tertiary
: Theme.of(ctx).colorScheme.outline,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton(
onPressed: () => _run(
context,
upscope.stopRemoteControl,
'Stop remote control',
),
child: const Text('Stop Remote Control'),
),
],
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Masked field section: demonstrates UpscopeMasked
// ---------------------------------------------------------------------------
class MaskedFieldSection extends StatefulWidget {
final Upscope upscope;
const MaskedFieldSection({super.key, required this.upscope});
@override
State<MaskedFieldSection> createState() => _MaskedFieldSectionState();
}
class _MaskedFieldSectionState extends State<MaskedFieldSection> {
final _cardController = TextEditingController();
@override
void dispose() {
_cardController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _SectionCard(
title: 'Masked Widget Demo',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'The field below is wrapped in UpscopeMasked — '
'its region is hidden from cobrowsing observers.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
UpscopeMasked(
child: TextField(
controller: _cardController,
decoration: const InputDecoration(
labelText: 'Card Number (masked)',
hintText: '•••• •••• •••• ••••',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.credit_card),
),
keyboardType: TextInputType.number,
obscureText: true,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Masking z-order repro: masked widget partially covered by an opaque overlay
// painted above it. The viewer must show the overlay on top of the mask.
// ---------------------------------------------------------------------------
class MaskingZOrderSection extends StatelessWidget {
const MaskingZOrderSection({super.key});
@override
Widget build(BuildContext context) {
return _SectionCard(
title: 'Masking z-order repro',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Agent should see a black box where the red box is, with the '
'white overlay strip visible on top (matching this screen).',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SizedBox(
height: 140,
child: Stack(
alignment: Alignment.center,
children: [
UpscopeMasked(
child: Container(
width: 200,
height: 100,
color: const Color(0xFFFF0000),
),
),
Container(
width: 220,
height: 50,
color: const Color(0xFFFFFFFF),
alignment: Alignment.center,
child: const Text(
'Overlay — must stay visible to agent',
style: TextStyle(color: Color(0xFF000000), fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Event log section
// ---------------------------------------------------------------------------
class EventLogSection extends StatelessWidget {
final List<String> events;
const EventLogSection({super.key, required this.events});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return _SectionCard(
title: 'Event Log',
child: events.isEmpty
? Text(
'No events yet. Initialize the SDK to start.',
style: textTheme.bodySmall?.copyWith(color: cs.outline),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: events
.map(
(e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
e,
style: textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: cs.onSurfaceVariant,
),
),
),
)
.toList(),
),
);
}
}
// ---------------------------------------------------------------------------
// Shared composable primitives
// ---------------------------------------------------------------------------
/// Generic card wrapper used by every section.
class _SectionCard extends StatelessWidget {
final String title;
final Widget child;
const _SectionCard({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
child,
],
),
),
);
}
}
/// A labeled row displaying a pre-resolved value (no stream subscription).
class _LabelValue extends StatelessWidget {
final String label;
final String? value;
const _LabelValue({required this.label, this.value});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Row(
children: [
SizedBox(
width: 96,
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.outline),
),
),
Expanded(
child: Text(
value ?? '—',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}
/// A labeled row that subscribes to a stream and displays its latest value.
class _StreamRow<T> extends StatelessWidget {
final String label;
final Stream<T> stream;
final String Function(T) format;
final Color Function(T, BuildContext)? colorFor;
const _StreamRow({
required this.label,
required this.stream,
required this.format,
this.colorFor,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Row(
children: [
SizedBox(
width: 96,
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.outline),
),
),
Expanded(
child: StreamBuilder<T>(
stream: stream,
builder: (ctx, snap) {
final value = snap.data;
final text = value != null ? format(value) : '—';
final color = value != null && colorFor != null
? colorFor!(value, ctx)
: cs.outline;
return Text(
text,
style: Theme.of(ctx).textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
);
},
),
),
],
);
}
}