Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
589 changes: 589 additions & 0 deletions example/lib/src/attended_transfer_screen.dart

Large diffs are not rendered by default.

245 changes: 227 additions & 18 deletions example/lib/src/callscreen.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import 'dart:async';
import 'dart:io' show Platform;
Comment thread
COACHIKO marked this conversation as resolved.

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:sip_ua/sip_ua.dart';

import 'attended_transfer_screen.dart';
import 'widgets/action_button.dart';

class CallScreenWidget extends StatefulWidget {
final SIPUAHelper? _helper;
final Call? _call;

CallScreenWidget(this._helper, this._call, {Key? key}) : super(key: key);
/// Optional list of other active calls for attended transfer.
/// Pass this when you have multiple active calls managed by your app.
final List<Call>? otherActiveCalls;

CallScreenWidget(this._helper, this._call, {Key? key, this.otherActiveCalls})
: super(key: key);
Comment thread
COACHIKO marked this conversation as resolved.

@override
State<CallScreenWidget> createState() => _MyCallScreenWidget();
Expand Down Expand Up @@ -101,6 +108,12 @@ class _MyCallScreenWidget extends State<CallScreenWidget>

@override
void callStateChanged(Call call, CallState callState) {
// Only handle state changes for this screen's call
// During attended transfer, other calls may fire events that we should ignore
if (call.id != this.call?.id) {
return;
}

if (callState.state == CallStateEnum.HOLD ||
callState.state == CallStateEnum.UNHOLD) {
_hold = callState.state == CallStateEnum.HOLD;
Expand Down Expand Up @@ -169,10 +182,18 @@ class _MyCallScreenWidget extends State<CallScreenWidget>

void _backToDialPad() {
_timer.cancel();
_cleanUp();

// Don't navigate if attended transfer is active - let AttendedTransferScreen handle navigation
if (AttendedTransferScreen.isAttendedTransferActive) {
return;
}

Timer(Duration(seconds: 2), () {
Navigator.of(context).pop();
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
Comment on lines 183 to 196

Copilot AI Jan 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup order may cause issues. The timer is cancelled and cleanup is called before checking if navigation should happen. If AttendedTransferScreen.isAttendedTransferActive is true and the method returns early, the delayed navigation in the Timer callback (line 192-196) will still execute after 2 seconds, potentially causing unexpected navigation.

Copilot uses AI. Check for mistakes.
_cleanUp();
}

void _handleStreams(CallState event) async {
Expand All @@ -182,10 +203,16 @@ class _MyCallScreenWidget extends State<CallScreenWidget>
_localRenderer!.srcObject = stream;
}

// enableSpeakerphone is not available on web, desktop, or macOS
if (!kIsWeb &&
!WebRTC.platformIsDesktop &&
!Platform.isMacOS &&
event.stream?.getAudioTracks().isNotEmpty == true) {
event.stream?.getAudioTracks().first.enableSpeakerphone(false);
try {
event.stream?.getAudioTracks().first.enableSpeakerphone(false);
} catch (e) {
// enableSpeakerphone not available on this platform
}
}
_localStream = stream;
}
Expand Down Expand Up @@ -289,27 +316,79 @@ class _MyCallScreenWidget extends State<CallScreenWidget>
void _handleTransfer() {
showDialog<void>(
context: context,
barrierDismissible: false,
barrierDismissible: true,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Enter target to transfer.'),
content: TextField(
onChanged: (String text) {
setState(() {
_transferTarget = text;
});
},
decoration: InputDecoration(
hintText: 'URI or Username',
title: Text('Transfer Call'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Choose transfer type:'),
SizedBox(height: 16),
],
),
actions: <Widget>[
TextButton(
child: Text('Blind Transfer'),
onPressed: () {
Navigator.of(context).pop();
_showBlindTransferDialog();
},
),
textAlign: TextAlign.center,
TextButton(
child: Text('Attended Transfer'),
onPressed: () {
Navigator.of(context).pop();
_showAttendedTransferDialog();
},
),
TextButton(
child: Text('Cancel'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}

/// Shows dialog for blind transfer (simple REFER without Replaces).
void _showBlindTransferDialog() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Blind Transfer'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Enter target to transfer call to:'),
SizedBox(height: 8),
TextField(
onChanged: (String text) {
setState(() {
_transferTarget = text;
});
},
decoration: InputDecoration(
hintText: 'URI or Username',
border: OutlineInputBorder(),
),
textAlign: TextAlign.center,
),
],
),
actions: <Widget>[
TextButton(
child: Text('Ok'),
child: Text('Transfer'),
onPressed: () {
// Use blind transfer (simple REFER without Replaces)
call!.refer(_transferTarget);
Navigator.of(context).pop();
_showTransferProgressSnackBar('Blind transfer initiated...');
},
),
TextButton(
Expand All @@ -324,6 +403,128 @@ class _MyCallScreenWidget extends State<CallScreenWidget>
);
}

/// Shows dialog for attended transfer (REFER with Replaces header).
/// User enters the target number, then the app holds the current call
/// and opens a new screen for the consultation call.
void _showAttendedTransferDialog() {
String attendedTransferTarget = '';

showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Attended Transfer'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.swap_horiz, size: 48, color: Colors.blue),
SizedBox(height: 16),
Text(
'Enter the number to consult with before transferring:',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14),
),
SizedBox(height: 16),
TextField(
autofocus: true,
onChanged: (String text) {
attendedTransferTarget = text;
},
decoration: InputDecoration(
hintText: 'URI or Phone Number',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_add),
),
textAlign: TextAlign.center,
keyboardType: TextInputType.phone,
),
SizedBox(height: 12),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.blue, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
'Your current call will be put on hold while you speak with the transfer target.',
style: TextStyle(fontSize: 12, color: Colors.blue[700]),
),
),
],
),
),
],
),
actions: <Widget>[
TextButton(
child: Text('Cancel'),
onPressed: () => Navigator.of(context).pop(),
),
ElevatedButton(
child: Text('Start Transfer'),
onPressed: () {
if (attendedTransferTarget.isNotEmpty) {
Navigator.of(context).pop();
_startAttendedTransferFlow(attendedTransferTarget);
}
},
),
],
);
},
);
}

/// Starts the attended transfer flow by navigating to the AttendedTransferScreen.
void _startAttendedTransferFlow(String targetUri) {
Navigator.of(context)
.push(
MaterialPageRoute(
builder: (context) => AttendedTransferScreen(
args: AttendedTransferArgs(
originalCall: call!,
targetUri: targetUri,
helper: helper!,
),
),
),
)
.then((result) {
// Handle when returning from attended transfer screen
if (result == true) {
// Transfer was successful - close this call screen too
_timer.cancel();
_cleanUp();
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
} else {
// Transfer was cancelled or failed
// Original call should be resumed (unhold is handled by AttendedTransferScreen)
if (mounted) {
setState(() {});
}
}
});
}

/// Shows a snackbar with transfer progress information.
void _showTransferProgressSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: Duration(seconds: 2),
backgroundColor: Colors.blue,
),
);
}

void _handleDtmf(String tone) {
print('Dtmf tone => $tone');
call!.sendDTMF(tone);
Expand Down Expand Up @@ -355,8 +556,16 @@ class _MyCallScreenWidget extends State<CallScreenWidget>
void _toggleSpeaker() {
if (_localStream != null) {
_speakerOn = !_speakerOn;
if (!kIsWeb) {
_localStream!.getAudioTracks()[0].enableSpeakerphone(_speakerOn);
// enableSpeakerphone is only available on mobile platforms (iOS/Android)
if (!kIsWeb &&
!Platform.isMacOS &&
!Platform.isLinux &&
!Platform.isWindows) {
try {
_localStream!.getAudioTracks()[0].enableSpeakerphone(_speakerOn);
} catch (e) {
// enableSpeakerphone not available on this platform
}
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion example/lib/src/dialpad.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sip_ua/sip_ua.dart';

import 'attended_transfer_screen.dart';
import 'widgets/action_button.dart';

class DialPadWidget extends StatefulWidget {
Expand Down Expand Up @@ -364,7 +365,11 @@ class _MyDialPadWidget extends State<DialPadWidget>
void callStateChanged(Call call, CallState callState) {
switch (callState.state) {
case CallStateEnum.CALL_INITIATION:
Navigator.pushNamed(context, '/callscreen', arguments: call);
// Don't navigate to call screen if attended transfer is active
// The AttendedTransferScreen will handle the consultation call
if (!AttendedTransferScreen.isAttendedTransferActive) {
Navigator.pushNamed(context, '/callscreen', arguments: call);
}
break;
case CallStateEnum.FAILED:
reRegisterWithCurrentUser();
Expand Down
43 changes: 43 additions & 0 deletions example/macos/Podfile.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
PODS:
- flutter_webrtc (1.2.0):
- FlutterMacOS
- WebRTC-SDK (= 137.7151.04)
- FlutterMacOS (1.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- WebRTC-SDK (137.7151.04)

DEPENDENCIES:
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)

SPEC REPOS:
trunk:
- WebRTC-SDK

EXTERNAL SOURCES:
flutter_webrtc:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos
FlutterMacOS:
:path: Flutter/ephemeral
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin

SPEC CHECKSUMS:
flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e

PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009

COCOAPODS: 1.16.2
Loading
Loading