From a2bbd33f43595e0f5b52bd137c0c07b266945d23 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 17 Jan 2025 15:22:07 +0000 Subject: [PATCH 01/45] feat(WEBRTC-2446): Adjust app themes to match Figma - Created theme.dart with black and white theme configuration - Updated main.dart to use the new theme - Set surface color to #FEFDF5 - Implemented black and white color scheme - Added theme configurations for buttons and inputs --- lib/main.dart | 6 +-- lib/utils/theme.dart | 105 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 lib/utils/theme.dart diff --git a/lib/main.dart b/lib/main.dart index e0f15f3..97eacf4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,7 +19,7 @@ import 'package:telnyx_webrtc/model/push_notification.dart'; import 'package:telnyx_webrtc/telnyx_client.dart'; import 'package:telnyx_webrtc/model/telnyx_message.dart'; import 'package:telnyx_webrtc/model/socket_method.dart'; - +import 'package:telnyx_flutter_webrtc/utils/theme.dart'; import 'package:telnyx_flutter_webrtc/firebase_options.dart'; final logger = Logger(); @@ -396,9 +396,7 @@ class _MyAppState extends State { ], child: MaterialApp( title: 'Telnyx WebRTC', - theme: ThemeData( - primarySwatch: Colors.blue, - ), + theme: AppTheme.lightTheme, initialRoute: '/', routes: { '/': (context) => const LoginScreen(), diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart new file mode 100644 index 0000000..b9c1127 --- /dev/null +++ b/lib/utils/theme.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +// Theme colors +const surfaceColor = Color(0xFFFEFDF5); +const primaryColor = Colors.black; +const secondaryColor = Colors.white; +const disabledColor = Color(0xFF808080); // Gray for disabled state + +class AppTheme { + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + textTheme: GoogleFonts.nunitoTextTheme(), + colorScheme: const ColorScheme.light( + primary: primaryColor, + onPrimary: secondaryColor, + secondary: secondaryColor, + onSecondary: primaryColor, + surface: surfaceColor, + background: surfaceColor, + ), + + // Input decoration theme + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceColor, + labelStyle: const TextStyle(color: primaryColor), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(color: primaryColor), + borderRadius: BorderRadius.circular(8), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: primaryColor, width: 2), + borderRadius: BorderRadius.circular(8), + ), + errorBorder: OutlineInputBorder( + borderSide: const BorderSide(color: primaryColor), + borderRadius: BorderRadius.circular(8), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: const BorderSide(color: primaryColor, width: 2), + borderRadius: BorderRadius.circular(8), + ), + ), + + // Elevated button theme + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + backgroundColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + return primaryColor; + }, + ), + foregroundColor: MaterialStateProperty.all(secondaryColor), + ), + ), + + // Outlined button theme + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: primaryColor), + ), + ), + backgroundColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledColor; + } + return secondaryColor; + }, + ), + foregroundColor: MaterialStateProperty.all(primaryColor), + ), + ), + + // App bar theme + appBarTheme: const AppBarTheme( + backgroundColor: surfaceColor, + foregroundColor: primaryColor, + elevation: 0, + ), + + // Scaffold background color + scaffoldBackgroundColor: surfaceColor, + ); + } +} \ No newline at end of file From f5bee5d0d07dc673d44a0562ede6ed5f80bccecb Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Fri, 17 Jan 2025 15:28:40 +0000 Subject: [PATCH 02/45] chore: fix deprecated classes and remove font --- lib/utils/theme.dart | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index b9c1127..b2e4938 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; // Theme colors const surfaceColor = Color(0xFFFEFDF5); @@ -11,14 +10,12 @@ class AppTheme { static ThemeData get lightTheme { return ThemeData( useMaterial3: true, - textTheme: GoogleFonts.nunitoTextTheme(), colorScheme: const ColorScheme.light( primary: primaryColor, onPrimary: secondaryColor, secondary: secondaryColor, onSecondary: primaryColor, surface: surfaceColor, - background: surfaceColor, ), // Input decoration theme @@ -47,47 +44,47 @@ class AppTheme { // Elevated button theme elevatedButtonTheme: ElevatedButtonThemeData( style: ButtonStyle( - padding: MaterialStateProperty.all( + padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), - backgroundColor: MaterialStateProperty.resolveWith( - (Set states) { - if (states.contains(MaterialState.disabled)) { + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { return disabledColor; } return primaryColor; }, ), - foregroundColor: MaterialStateProperty.all(secondaryColor), + foregroundColor: WidgetStateProperty.all(secondaryColor), ), ), // Outlined button theme outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( - padding: MaterialStateProperty.all( + padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: const BorderSide(color: primaryColor), ), ), - backgroundColor: MaterialStateProperty.resolveWith( - (Set states) { - if (states.contains(MaterialState.disabled)) { + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { return disabledColor; } return secondaryColor; }, ), - foregroundColor: MaterialStateProperty.all(primaryColor), + foregroundColor: WidgetStateProperty.all(primaryColor), ), ), From e86619deb8ffccd34f71c6e450187914cccae567 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Fri, 17 Jan 2025 15:30:54 +0000 Subject: [PATCH 03/45] chore: add textButtonTheme --- lib/utils/theme.dart | 23 +++++++++++++++++++++++ lib/view/screen/login_screen.dart | 3 --- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index b2e4938..67d450f 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -64,6 +64,29 @@ class AppTheme { ), ), + // Text button theme + textButtonTheme: TextButtonThemeData( + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) { + return disabledColor; + } + return primaryColor; + }, + ), + foregroundColor: WidgetStateProperty.all(secondaryColor), + ), + ), + // Outlined button theme outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( diff --git a/lib/view/screen/login_screen.dart b/lib/view/screen/login_screen.dart index fc4b077..09d675d 100644 --- a/lib/view/screen/login_screen.dart +++ b/lib/view/screen/login_screen.dart @@ -190,9 +190,6 @@ class _LoginScreenState extends State with WidgetsBindingObserver { Padding( padding: const EdgeInsets.all(8.0), child: TextButton( - style: TextButton.styleFrom( - foregroundColor: Colors.blue, - ), onPressed: () { _attemptLogin(); }, From d3ee364b8e9e0a5ee1f222a3b841b4c2baef2bb1 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Fri, 17 Jan 2025 15:44:22 +0000 Subject: [PATCH 04/45] fix: remove blue from button --- lib/view/screen/home_screen.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/view/screen/home_screen.dart b/lib/view/screen/home_screen.dart index 70ffc70..b5cedd3 100644 --- a/lib/view/screen/home_screen.dart +++ b/lib/view/screen/home_screen.dart @@ -122,9 +122,6 @@ class _HomeScreenState extends State { Padding( padding: const EdgeInsets.all(8.0), child: TextButton( - style: TextButton.styleFrom( - foregroundColor: Colors.blue, - ), onPressed: () { _callDestination(); }, From 0baf051448ac2f06b8e1431e7164904bb4aa9c49 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 20 Jan 2025 12:05:07 +0000 Subject: [PATCH 05/45] feat: begin migration to new design and architecture --- .idea/libraries/Flutter_Plugins.xml | 26 ++-- lib/main.dart | 2 +- lib/{ => view}/main_view_model.dart | 1 + lib/view/screen/call_screen.dart | 2 +- lib/view/screen/home_screen.dart | 2 +- lib/view/screen/homes_screen.dart | 156 ++++++++++++++++++++++++ lib/view/screen/login_screen.dart | 2 +- lib/view/widgets/invitation_widget.dart | 2 +- pubspec.yaml | 1 + 9 files changed, 176 insertions(+), 18 deletions(-) rename lib/{ => view}/main_view_model.dart (99%) create mode 100644 lib/view/screen/homes_screen.dart diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml index 5beae97..eca295a 100644 --- a/.idea/libraries/Flutter_Plugins.xml +++ b/.idea/libraries/Flutter_Plugins.xml @@ -1,31 +1,31 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/lib/main.dart b/lib/main.dart index 97eacf4..7d3d86e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; -import 'package:telnyx_flutter_webrtc/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; import 'package:telnyx_flutter_webrtc/service/notification_service.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; import 'package:telnyx_flutter_webrtc/view/screen/home_screen.dart'; diff --git a/lib/main_view_model.dart b/lib/view/main_view_model.dart similarity index 99% rename from lib/main_view_model.dart rename to lib/view/main_view_model.dart index bca419b..1584795 100644 --- a/lib/main_view_model.dart +++ b/lib/view/main_view_model.dart @@ -21,6 +21,7 @@ import 'package:telnyx_webrtc/model/push_notification.dart'; import 'package:telnyx_webrtc/model/call_state.dart'; enum CallStateStatus { + disconnected, idle, ringing, ongoingInvitation, diff --git a/lib/view/screen/call_screen.dart b/lib/view/screen/call_screen.dart index 4e62700..057101e 100644 --- a/lib/view/screen/call_screen.dart +++ b/lib/view/screen/call_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; import 'package:provider/provider.dart'; -import 'package:telnyx_flutter_webrtc/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/dialpad_widget.dart'; import 'package:telnyx_webrtc/call.dart'; diff --git a/lib/view/screen/home_screen.dart b/lib/view/screen/home_screen.dart index b5cedd3..a93bc46 100644 --- a/lib/view/screen/home_screen.dart +++ b/lib/view/screen/home_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:telnyx_flutter_webrtc/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; import 'package:provider/provider.dart'; import 'package:logger/logger.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; diff --git a/lib/view/screen/homes_screen.dart b/lib/view/screen/homes_screen.dart new file mode 100644 index 0000000..65c8c05 --- /dev/null +++ b/lib/view/screen/homes_screen.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class HomesScreen extends StatefulWidget { + const HomesScreen({super.key}); + + @override + State createState() => _HomesScreenState(); +} + +class _HomesScreenState extends State { + + @override + void initState() { + super.initState(); + askForNotificationPermission(); + } + + Future askForNotificationPermission() async { + await FlutterCallkitIncoming.requestNotificationPermission('notification'); + final status = await Permission.notification.status; + if (status.isDenied) { + await Permission.notification.request(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SingleChildScrollView( + child: Column( + children: [ + const ControlHeaders(isConnected: true), + const SizedBox(height: 10), + ], + ), + ), + ); + } +} + +class LoginControls extends StatefulWidget { + const LoginControls({super.key}); + + @override + State createState() => _LoginControlsState(); +} + +class _LoginControlsState extends State { + bool isTokenLogin = false; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Text('Token Login'), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Switch( + value: isTokenLogin, + onChanged: (value) { + setState(() { + isTokenLogin = value; + }); + }, + ), + Text(isTokenLogin ? 'On' : 'Off'), + ], + ), + const SizedBox(height: 20), + const Text('Profile'), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text('User'), + ElevatedButton( + onPressed: () { + // Open Bottom Sheet + }, + child: const Text('Switch Profile'), + ), + ], + ), + Spacer(), + ElevatedButton( + onPressed: () { + // Logout + }, + child: const Text('Connect'), + ), + ], + ); + } +} + +class ControlHeaders extends StatefulWidget { + final bool isConnected; + + const ControlHeaders({super.key, required this.isConnected}); + + @override + State createState() => _ControlHeadersState(); +} + +class _ControlHeadersState extends State { + @override + Widget build(BuildContext context) { + return Column( + children: [ + SvgPicture.asset('assets/telnyx_logo.svg'), + Text( + widget.isConnected + ? 'Enter a destination (+E164 phone number or sip URI) to initiate your call.' + : 'Please confirm details below and click ‘Connect’ to make a call.', + ), + const SizedBox(height: 20), + const Text('Socket'), + const SizedBox(height: 10), + SocketConnectivityStatus(isConnected: widget.isConnected), + const SizedBox(height: 20), + const Text('Session ID'), + const SizedBox(height: 10), + const Text('-'), + ], + ); + } +} + +class SocketConnectivityStatus extends StatelessWidget { + final bool isConnected; + + const SocketConnectivityStatus({super.key, required this.isConnected}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: isConnected ? Colors.green : Colors.red, + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(width: 10), + Text(isConnected ? 'Client-ready' : 'Disconnected'), + ], + ); + } +} diff --git a/lib/view/screen/login_screen.dart b/lib/view/screen/login_screen.dart index 09d675d..2395a9a 100644 --- a/lib/view/screen/login_screen.dart +++ b/lib/view/screen/login_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:telnyx_flutter_webrtc/main.dart'; -import 'package:telnyx_flutter_webrtc/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; import 'package:provider/provider.dart'; import 'package:logger/logger.dart'; import 'package:permission_handler/permission_handler.dart'; diff --git a/lib/view/widgets/invitation_widget.dart b/lib/view/widgets/invitation_widget.dart index 59c2add..ac6f054 100644 --- a/lib/view/widgets/invitation_widget.dart +++ b/lib/view/widgets/invitation_widget.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:telnyx_flutter_webrtc/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; import 'package:provider/provider.dart'; import 'package:telnyx_webrtc/model/verto/receive/received_message_body.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 5e94572..75872cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: sdk: flutter cupertino_icons: ^1.0.2 + flutter_svg: ^2.0.17 firebase_messaging: ^15.1.6 firebase_core: ^3.8.1 flutter_callkit_incoming: ^2.0.4+1 From aedfa7513caa5afd5dca451fc1f606a63ef34e2b Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 20 Jan 2025 12:06:31 +0000 Subject: [PATCH 06/45] chore: no requirement for multiprovider --- lib/main.dart | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 7d3d86e..392d3e8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -290,13 +290,13 @@ Future main() async { { mainViewModel.login(credentialConfig), }, - }, + }, FGBGType.background => { logger.i( 'We are in the background setting fromBackground == true, DISCONNECTING', ), - fromBackground = true, - mainViewModel.disconnect(), + fromBackground = true, + mainViewModel.disconnect(), } }, child: const MyApp(), @@ -387,13 +387,10 @@ class _MyAppState extends State { } } -// This widget is the root of your application. @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: mainViewModel), - ], + return ChangeNotifierProvider( + create: (context) => mainViewModel, child: MaterialApp( title: 'Telnyx WebRTC', theme: AppTheme.lightTheme, From 65622e06244763e094723dd8880dca6412e969cc Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 20 Jan 2025 12:41:04 +0000 Subject: [PATCH 07/45] feat: adjust ViewModel usage to use consumer and select on home screen --- lib/main.dart | 4 +- lib/view/screen/call_screen.dart | 12 +- lib/view/screen/home_screen.dart | 18 +-- lib/view/screen/homes_screen.dart | 137 +++--------------- lib/view/screen/login_screen.dart | 10 +- ...del.dart => telnyx_client_view_model.dart} | 2 +- lib/view/widgets/dialpad_widget.dart | 2 +- lib/view/widgets/header/control_header.dart | 63 ++++++++ lib/view/widgets/invitation_widget.dart | 4 +- lib/view/widgets/login/login_controls.dart | 58 ++++++++ pubspec.yaml | 2 +- 11 files changed, 169 insertions(+), 143 deletions(-) rename lib/view/{main_view_model.dart => telnyx_client_view_model.dart} (99%) create mode 100644 lib/view/widgets/header/control_header.dart create mode 100644 lib/view/widgets/login/login_controls.dart diff --git a/lib/main.dart b/lib/main.dart index 392d3e8..4cfe8a3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; -import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/service/notification_service.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; import 'package:telnyx_flutter_webrtc/view/screen/home_screen.dart'; @@ -23,7 +23,7 @@ import 'package:telnyx_flutter_webrtc/utils/theme.dart'; import 'package:telnyx_flutter_webrtc/firebase_options.dart'; final logger = Logger(); -final mainViewModel = MainViewModel(); +final mainViewModel = TelnyxClientViewModel(); const MOCK_USER = ''; const MOCK_PASSWORD = ''; const CALL_MISSED_TIMEOUT = 30; diff --git a/lib/view/screen/call_screen.dart b/lib/view/screen/call_screen.dart index 057101e..757b20a 100644 --- a/lib/view/screen/call_screen.dart +++ b/lib/view/screen/call_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; import 'package:provider/provider.dart'; -import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/dialpad_widget.dart'; import 'package:telnyx_webrtc/call.dart'; @@ -36,13 +36,13 @@ class _CallScreenState extends State { dialButtonColor: Colors.red, makeCall: (number) { //End call - Provider.of(context, listen: false) + Provider.of(context, listen: false) .endCall(endfromCallScreen: true); }, keyPressed: (number) { callInputController.text = callInputController.value.text + number; - Provider.of(context, listen: false) + Provider.of(context, listen: false) .dtmf(number); }, ), @@ -53,7 +53,7 @@ class _CallScreenState extends State { IconButton( onPressed: () { print('mic'); - Provider.of(context, listen: false) + Provider.of(context, listen: false) .muteUnmute(); }, icon: const Icon(Icons.mic), @@ -61,7 +61,7 @@ class _CallScreenState extends State { IconButton( onPressed: () { print('speakerphone'); - Provider.of(context, listen: false) + Provider.of(context, listen: false) .toggleSpeakerPhone(); }, icon: const Icon(Icons.volume_up), @@ -69,7 +69,7 @@ class _CallScreenState extends State { IconButton( onPressed: () { print('pause'); - Provider.of(context, listen: false) + Provider.of(context, listen: false) .holdUnhold(); }, icon: const Icon(Icons.pause), diff --git a/lib/view/screen/home_screen.dart b/lib/view/screen/home_screen.dart index a93bc46..5b6eb61 100644 --- a/lib/view/screen/home_screen.dart +++ b/lib/view/screen/home_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:provider/provider.dart'; import 'package:logger/logger.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; @@ -41,34 +41,34 @@ class _HomeScreenState extends State { } void _observeResponses() { - invitation = Provider.of(context, listen: true).callState == + invitation = Provider.of(context, listen: true).callState == CallStateStatus.ongoingInvitation; - ongoingCall = Provider.of(context, listen: true).callState == + ongoingCall = Provider.of(context, listen: true).callState == CallStateStatus.ongoingCall; } void _callDestination() { - Provider.of(context, listen: false) + Provider.of(context, listen: false) .call(destinationController.text); logger.i('Calling!'); } void _endCall() { - Provider.of(context, listen: false).endCall(); + Provider.of(context, listen: false).endCall(); logger.i('Calling!'); } void handleOptionClick(String value) { switch (value) { case 'Logout': - Provider.of(context, listen: false).disconnect(); + Provider.of(context, listen: false).disconnect(); WidgetsBinding.instance.addPostFrameCallback((_) { Navigator.of(context).pushReplacementNamed('/'); }); logger.i('Disconnecting!'); break; case 'Export Logs': - Provider.of(context, listen: false).exportLogs(); + Provider.of(context, listen: false).exportLogs(); logger.i('Exporting logs!'); break; } @@ -80,12 +80,12 @@ class _HomeScreenState extends State { if (invitation) { return InvitationWidget( title: 'Home', - invitation: Provider.of(context, listen: false) + invitation: Provider.of(context, listen: false) .incomingInvitation, ); } else if (ongoingCall) { return CallScreen( - call: Provider.of(context, listen: false).currentCall, + call: Provider.of(context, listen: false).currentCall, ); } else { return Scaffold( diff --git a/lib/view/screen/homes_screen.dart b/lib/view/screen/homes_screen.dart index 65c8c05..80a975a 100644 --- a/lib/view/screen/homes_screen.dart +++ b/lib/view/screen/homes_screen.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/header/control_header.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/invitation_widget.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/login/login_controls.dart'; class HomesScreen extends StatefulWidget { const HomesScreen({super.key}); @@ -28,129 +33,29 @@ class _HomesScreenState extends State { @override Widget build(BuildContext context) { + final clientState = context.select ( + (txClient) => txClient.callState + ); + return Scaffold( body: SingleChildScrollView( child: Column( children: [ - const ControlHeaders(isConnected: true), + const ControlHeaders(), const SizedBox(height: 10), + if (clientState == CallStateStatus.disconnected) + const LoginControls(), + if (clientState == CallStateStatus.idle) + Text('Destination'), + if (clientState == CallStateStatus.ringing) + const Text('Ringing'), + if (clientState == CallStateStatus.ongoingInvitation) + const InvitationWidget(title: '',), + if (clientState == CallStateStatus.ongoingCall) + const CallScreen(), ], ), ), ); } } - -class LoginControls extends StatefulWidget { - const LoginControls({super.key}); - - @override - State createState() => _LoginControlsState(); -} - -class _LoginControlsState extends State { - bool isTokenLogin = false; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - const Text('Token Login'), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Switch( - value: isTokenLogin, - onChanged: (value) { - setState(() { - isTokenLogin = value; - }); - }, - ), - Text(isTokenLogin ? 'On' : 'Off'), - ], - ), - const SizedBox(height: 20), - const Text('Profile'), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text('User'), - ElevatedButton( - onPressed: () { - // Open Bottom Sheet - }, - child: const Text('Switch Profile'), - ), - ], - ), - Spacer(), - ElevatedButton( - onPressed: () { - // Logout - }, - child: const Text('Connect'), - ), - ], - ); - } -} - -class ControlHeaders extends StatefulWidget { - final bool isConnected; - - const ControlHeaders({super.key, required this.isConnected}); - - @override - State createState() => _ControlHeadersState(); -} - -class _ControlHeadersState extends State { - @override - Widget build(BuildContext context) { - return Column( - children: [ - SvgPicture.asset('assets/telnyx_logo.svg'), - Text( - widget.isConnected - ? 'Enter a destination (+E164 phone number or sip URI) to initiate your call.' - : 'Please confirm details below and click ‘Connect’ to make a call.', - ), - const SizedBox(height: 20), - const Text('Socket'), - const SizedBox(height: 10), - SocketConnectivityStatus(isConnected: widget.isConnected), - const SizedBox(height: 20), - const Text('Session ID'), - const SizedBox(height: 10), - const Text('-'), - ], - ); - } -} - -class SocketConnectivityStatus extends StatelessWidget { - final bool isConnected; - - const SocketConnectivityStatus({super.key, required this.isConnected}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: isConnected ? Colors.green : Colors.red, - borderRadius: BorderRadius.circular(5), - ), - ), - const SizedBox(width: 10), - Text(isConnected ? 'Client-ready' : 'Disconnected'), - ], - ); - } -} diff --git a/lib/view/screen/login_screen.dart b/lib/view/screen/login_screen.dart index 2395a9a..e4466c5 100644 --- a/lib/view/screen/login_screen.dart +++ b/lib/view/screen/login_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:telnyx_flutter_webrtc/main.dart'; -import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:provider/provider.dart'; import 'package:logger/logger.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -76,7 +76,7 @@ class _LoginScreenState extends State with WidgetsBindingObserver { ringbackPath: 'assets/audio/ringback_tone.mp3', ); setState(() { - Provider.of(context, listen: false) + Provider.of(context, listen: false) .login(credentialConfig!); }); } @@ -122,12 +122,12 @@ class _LoginScreenState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - Provider.of(context, listen: true).observeResponses(); + Provider.of(context, listen: true).observeResponses(); final bool registered = - Provider.of(context, listen: true).registered; + Provider.of(context, listen: true).registered; final bool isLoggingIn = - Provider.of(context, listen: true).loggingIn; + Provider.of(context, listen: true).loggingIn; if (registered) { WidgetsBinding.instance.addPostFrameCallback((_) { Navigator.pushReplacementNamed(context, '/home'); diff --git a/lib/view/main_view_model.dart b/lib/view/telnyx_client_view_model.dart similarity index 99% rename from lib/view/main_view_model.dart rename to lib/view/telnyx_client_view_model.dart index 1584795..a87314d 100644 --- a/lib/view/main_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -28,7 +28,7 @@ enum CallStateStatus { ongoingCall, } -class MainViewModel with ChangeNotifier { +class TelnyxClientViewModel with ChangeNotifier { final logger = Logger(); final TelnyxClient _telnyxClient = TelnyxClient(); diff --git a/lib/view/widgets/dialpad_widget.dart b/lib/view/widgets/dialpad_widget.dart index c673941..22f2217 100644 --- a/lib/view/widgets/dialpad_widget.dart +++ b/lib/view/widgets/dialpad_widget.dart @@ -64,7 +64,7 @@ class DialPadState extends State { super.initState(); } - _setText(String? value) async { + void _setText(String? value) async { if (widget.keyPressed != null) widget.keyPressed!(value!); setState(() { diff --git a/lib/view/widgets/header/control_header.dart b/lib/view/widgets/header/control_header.dart new file mode 100644 index 0000000..6e28024 --- /dev/null +++ b/lib/view/widgets/header/control_header.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; + +class ControlHeaders extends StatefulWidget { + const ControlHeaders({super.key}); + + @override + State createState() => _ControlHeadersState(); +} + +class _ControlHeadersState extends State { + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, txClient, child) { + return Column( + children: [ + SvgPicture.asset('assets/telnyx_logo.svg'), + Text( + txClient.registered + ? 'Enter a destination (+E164 phone number or sip URI) to initiate your call.' + : 'Please confirm details below and click ‘Connect’ to make a call.', + ), + const SizedBox(height: 20), + const Text('Socket'), + const SizedBox(height: 10), + SocketConnectivityStatus(isConnected: txClient.registered), + const SizedBox(height: 20), + const Text('Session ID'), + const SizedBox(height: 10), + const Text('-'), + ], + ); + }, + ); + } +} + +class SocketConnectivityStatus extends StatelessWidget { + final bool isConnected; + + const SocketConnectivityStatus({super.key, required this.isConnected}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: isConnected ? Colors.green : Colors.red, + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(width: 10), + Text(isConnected ? 'Client-ready' : 'Disconnected'), + ], + ); + } +} diff --git a/lib/view/widgets/invitation_widget.dart b/lib/view/widgets/invitation_widget.dart index ac6f054..d22cbbc 100644 --- a/lib/view/widgets/invitation_widget.dart +++ b/lib/view/widgets/invitation_widget.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:telnyx_flutter_webrtc/view/main_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:provider/provider.dart'; import 'package:telnyx_webrtc/model/verto/receive/received_message_body.dart'; @@ -29,7 +29,7 @@ class InvitationWidget extends StatelessWidget { foregroundColor: Colors.red[400], ), onPressed: () { - Provider.of(context, listen: false) + Provider.of(context, listen: false) .endCall(); print('Decline Call'); }, diff --git a/lib/view/widgets/login/login_controls.dart b/lib/view/widgets/login/login_controls.dart new file mode 100644 index 0000000..cbf1acc --- /dev/null +++ b/lib/view/widgets/login/login_controls.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class LoginControls extends StatefulWidget { + const LoginControls({super.key}); + + @override + State createState() => _LoginControlsState(); +} + +class _LoginControlsState extends State { + bool isTokenLogin = false; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Text('Token Login'), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Switch( + value: isTokenLogin, + onChanged: (value) { + setState(() { + isTokenLogin = value; + }); + }, + ), + Text(isTokenLogin ? 'On' : 'Off'), + ], + ), + const SizedBox(height: 20), + const Text('Profile'), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text('User'), + ElevatedButton( + onPressed: () { + // Open Bottom Sheet + }, + child: const Text('Switch Profile'), + ), + ], + ), + Spacer(), + ElevatedButton( + onPressed: () { + // Logout + }, + child: const Text('Connect'), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 75872cc..1b5e610 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - provider: ^6.0.2 + provider: ^6.1.2 flutter_masked_text2: ^0.9.1 fluttertoast: ^8.0.9 telnyx_webrtc: From 77a64d16a92cb0fff4d15129968063d50f72123d Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 20 Jan 2025 16:39:23 +0000 Subject: [PATCH 08/45] feat: implement dimensions and asset paths and use provider to listen for state --- assets/{ => images}/telnyx_logo.png | Bin assets/telnyx_logo.svg | 8 --- lib/main.dart | 3 +- lib/utils/asset_paths.dart | 1 + lib/utils/dimensions.dart | 17 ++++++ lib/utils/theme.dart | 51 +++++++++--------- lib/view/screen/homes_screen.dart | 39 +++++++------- lib/view/screen/login_screen.dart | 6 --- lib/view/telnyx_client_view_model.dart | 10 ++-- lib/view/widgets/header/control_header.dart | 28 +++++++--- lib/view/widgets/login/login_controls.dart | 47 ++++++++++------ packages/telnyx_webrtc/lib/telnyx_client.dart | 8 +-- pubspec.yaml | 6 +-- 13 files changed, 127 insertions(+), 97 deletions(-) rename assets/{ => images}/telnyx_logo.png (100%) delete mode 100644 assets/telnyx_logo.svg create mode 100644 lib/utils/asset_paths.dart create mode 100644 lib/utils/dimensions.dart diff --git a/assets/telnyx_logo.png b/assets/images/telnyx_logo.png similarity index 100% rename from assets/telnyx_logo.png rename to assets/images/telnyx_logo.png diff --git a/assets/telnyx_logo.svg b/assets/telnyx_logo.svg deleted file mode 100644 index 4e70775..0000000 --- a/assets/telnyx_logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/lib/main.dart b/lib/main.dart index 4cfe8a3..b1c469c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; +import 'package:telnyx_flutter_webrtc/view/screen/homes_screen.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/service/notification_service.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; @@ -396,7 +397,7 @@ class _MyAppState extends State { theme: AppTheme.lightTheme, initialRoute: '/', routes: { - '/': (context) => const LoginScreen(), + '/': (context) => const HomesScreen(), '/home': (context) => const HomeScreen(), '/call': (context) => const CallScreen(), }, diff --git a/lib/utils/asset_paths.dart b/lib/utils/asset_paths.dart new file mode 100644 index 0000000..5ef30f9 --- /dev/null +++ b/lib/utils/asset_paths.dart @@ -0,0 +1 @@ +const logo_path = 'assets/images/telnyx_logo.png'; diff --git a/lib/utils/dimensions.dart b/lib/utils/dimensions.dart new file mode 100644 index 0000000..69708f1 --- /dev/null +++ b/lib/utils/dimensions.dart @@ -0,0 +1,17 @@ +const double spacingXXS = 2.0; +const double spacingXS = 4.0; +const double spacingS = 8.0; +const double spacingM = 12.0; +const double spacingL = 16.0; +const double spacingXL = 20.0; +const double spacingXXL = 24.0; +const double spacingXXXL = 28.0; +const double spacingXXXXL = 32.0; +const double spacingXXXXXL = 36.0; + +const fontSizeXS = 8.0; +const fontSizeS = 12.0; +const fontSizeM = 16.0; +const fontSizeL = 18.0; +const fontSizeXL = 24.0; +const fontSizeXXL = 32.0; diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index 67d450f..fed799d 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -5,6 +5,8 @@ const surfaceColor = Color(0xFFFEFDF5); const primaryColor = Colors.black; const secondaryColor = Colors.white; const disabledColor = Color(0xFF808080); // Gray for disabled state +const telnyx_soft_black = Color(0xFF272727); +const telnyx_grey = Color(0xFF525252); class AppTheme { static ThemeData get lightTheme { @@ -18,6 +20,24 @@ class AppTheme { surface: surfaceColor, ), + textTheme: TextTheme( + headlineMedium: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w400, + color: telnyx_soft_black, + ), + bodyMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: telnyx_soft_black, + ), + labelMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: telnyx_grey, + ), + ), + // Input decoration theme inputDecorationTheme: InputDecorationTheme( filled: true, @@ -49,30 +69,7 @@ class AppTheme { ), shape: WidgetStateProperty.all( RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - backgroundColor: WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.disabled)) { - return disabledColor; - } - return primaryColor; - }, - ), - foregroundColor: WidgetStateProperty.all(secondaryColor), - ), - ), - - // Text button theme - textButtonTheme: TextButtonThemeData( - style: ButtonStyle( - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(24), ), ), backgroundColor: WidgetStateProperty.resolveWith( @@ -95,8 +92,8 @@ class AppTheme { ), shape: WidgetStateProperty.all( RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: const BorderSide(color: primaryColor), + borderRadius: BorderRadius.circular(16), + side: const BorderSide(style: BorderStyle.solid, color: Colors.red, width: 2), ), ), backgroundColor: WidgetStateProperty.resolveWith( @@ -122,4 +119,4 @@ class AppTheme { scaffoldBackgroundColor: surfaceColor, ); } -} \ No newline at end of file +} diff --git a/lib/view/screen/homes_screen.dart b/lib/view/screen/homes_screen.dart index 80a975a..03ad769 100644 --- a/lib/view/screen/homes_screen.dart +++ b/lib/view/screen/homes_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/header/control_header.dart'; @@ -16,7 +17,6 @@ class HomesScreen extends StatefulWidget { } class _HomesScreenState extends State { - @override void initState() { super.initState(); @@ -33,27 +33,30 @@ class _HomesScreenState extends State { @override Widget build(BuildContext context) { - final clientState = context.select ( - (txClient) => txClient.callState + final clientState = context.select( + (txClient) => txClient.callState, ); return Scaffold( body: SingleChildScrollView( - child: Column( - children: [ - const ControlHeaders(), - const SizedBox(height: 10), - if (clientState == CallStateStatus.disconnected) - const LoginControls(), - if (clientState == CallStateStatus.idle) - Text('Destination'), - if (clientState == CallStateStatus.ringing) - const Text('Ringing'), - if (clientState == CallStateStatus.ongoingInvitation) - const InvitationWidget(title: '',), - if (clientState == CallStateStatus.ongoingCall) - const CallScreen(), - ], + child: Padding( + padding: const EdgeInsets.all(spacingL), + child: Column( + children: [ + const ControlHeaders(), + const SizedBox(height: spacingS), + if (clientState == CallStateStatus.disconnected) + const LoginControls(), + if (clientState == CallStateStatus.idle) Text('Destination'), + if (clientState == CallStateStatus.ringing) const Text('Ringing'), + if (clientState == CallStateStatus.ongoingInvitation) + const InvitationWidget( + title: '', + ), + if (clientState == CallStateStatus.ongoingCall) + const CallScreen(), + ], + ), ), ), ); diff --git a/lib/view/screen/login_screen.dart b/lib/view/screen/login_screen.dart index e4466c5..c97de03 100644 --- a/lib/view/screen/login_screen.dart +++ b/lib/view/screen/login_screen.dart @@ -114,12 +114,6 @@ class _LoginScreenState extends State with WidgetsBindingObserver { logger.i('Saved credentials for auto login'); } - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - if (state == AppLifecycleState.resumed) {} - } - @override Widget build(BuildContext context) { Provider.of(context, listen: true).observeResponses(); diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index a87314d..aff4eed 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -50,7 +50,7 @@ class TelnyxClientViewModel with ChangeNotifier { return _loggingIn; } - CallStateStatus _callState = CallStateStatus.idle; + CallStateStatus _callState = CallStateStatus.disconnected; CallStateStatus get callState => _callState; @@ -73,7 +73,7 @@ class TelnyxClientViewModel with ChangeNotifier { } void resetCallInfo() { - logger.i('Mainviewmodel :: Reset Call Info'); + logger.i('TxClientViewModel :: Reset Call Info'); _incomingInvite = null; callState = CallStateStatus.idle; updateCallFromPush(false); @@ -154,7 +154,7 @@ class TelnyxClientViewModel with ChangeNotifier { _telnyxClient ..onSocketMessageReceived = (TelnyxMessage message) async { logger.i( - 'Mainviewmodel :: observeResponses :: Socket :: ${message.message}'); + 'TxClientViewModel :: observeResponses :: Socket :: ${message.message}'); switch (message.socketMethod) { case SocketMethod.clientReady: { @@ -163,8 +163,9 @@ class TelnyxClientViewModel with ChangeNotifier { } _registered = true; logger.i( - 'Mainviewmodel :: observeResponses : Registered :: $_registered', + 'TxClientViewModel :: observeResponses : Registered :: $_registered', ); + _callState = CallStateStatus.idle; break; } case SocketMethod.invite: @@ -304,6 +305,7 @@ class TelnyxClientViewModel with ChangeNotifier { _localNumber = credentialConfig.sipCallerIDNumber; _credentialConfig = credentialConfig; _telnyxClient.connectWithCredential(credentialConfig); + observeResponses(); } void loginWithToken(TokenConfig tokenConfig) { diff --git a/lib/view/widgets/header/control_header.dart b/lib/view/widgets/header/control_header.dart index 6e28024..d0ca2d2 100644 --- a/lib/view/widgets/header/control_header.dart +++ b/lib/view/widgets/header/control_header.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/utils/asset_paths.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; class ControlHeaders extends StatefulWidget { @@ -16,20 +17,31 @@ class _ControlHeadersState extends State { return Consumer( builder: (context, txClient, child) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SvgPicture.asset('assets/telnyx_logo.svg'), + Padding( + padding: const EdgeInsets.symmetric(vertical: 58), + child: Center( + child: Image.asset( + logo_path, + width: 222, + height: 58, + ), + ), + ), Text( txClient.registered ? 'Enter a destination (+E164 phone number or sip URI) to initiate your call.' : 'Please confirm details below and click ‘Connect’ to make a call.', + style: Theme.of(context).textTheme.headlineMedium, ), - const SizedBox(height: 20), - const Text('Socket'), - const SizedBox(height: 10), + const SizedBox(height: spacingXL), + Text('Socket', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: spacingS), SocketConnectivityStatus(isConnected: txClient.registered), - const SizedBox(height: 20), - const Text('Session ID'), - const SizedBox(height: 10), + const SizedBox(height: spacingXL), + Text('Session ID', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: spacingS), const Text('-'), ], ); diff --git a/lib/view/widgets/login/login_controls.dart b/lib/view/widgets/login/login_controls.dart index cbf1acc..09c3df0 100644 --- a/lib/view/widgets/login/login_controls.dart +++ b/lib/view/widgets/login/login_controls.dart @@ -1,4 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; +import 'package:telnyx_webrtc/config/telnyx_config.dart'; class LoginControls extends StatefulWidget { const LoginControls({super.key}); @@ -13,11 +17,11 @@ class _LoginControlsState extends State { @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Token Login'), - const SizedBox(height: 10), + Text('Token Login', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: spacingS), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Switch( value: isTokenLogin, @@ -27,30 +31,39 @@ class _LoginControlsState extends State { }); }, ), + SizedBox(width: spacingS), Text(isTokenLogin ? 'On' : 'Off'), ], ), - const SizedBox(height: 20), - const Text('Profile'), - const SizedBox(height: 10), + const SizedBox(height: spacingXL), + Text('Profile', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: spacingS), Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Text('User'), - ElevatedButton( - onPressed: () { - // Open Bottom Sheet - }, + SizedBox(width: spacingS), + TextButton( + onPressed: () {}, child: const Text('Switch Profile'), ), ], ), - Spacer(), - ElevatedButton( - onPressed: () { - // Logout - }, - child: const Text('Connect'), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Provider.of(context, listen: false).login( + CredentialConfig( + sipUser: 'placeholder', + sipPassword: 'placeholder', + sipCallerIDName: 'placeholder', + sipCallerIDNumber: 'placeholder', + debug: false, + ), + ); + }, + child: const Text('Connect'), + ), ), ], ); diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index bab1efa..86fcc64 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -47,24 +47,24 @@ class TelnyxClient { case SocketMethod.invite: { _logger.i( - 'TelnyxClient :: onSocketMessageReceived Override this on client side: $message', + 'TelnyxClient :: onSocketMessageReceived Override this on client side: ${message.message}', ); break; } case SocketMethod.bye: { _logger.i( - 'TelnyxClient :: onSocketMessageReceived Override this on client side: $message', + 'TelnyxClient :: onSocketMessageReceived Override this on client side: ${message.message}', ); break; } default: _logger.i( - 'TelnyxClient :: onSocketMessageReceived Override this on client side: $message', + 'TelnyxClient :: onSocketMessageReceived Override this on client side: ${message.message}', ); } _logger.i( - 'TelnyxClient :: onSocketMessageReceived Override this on client side: $message', + 'TelnyxClient :: onSocketMessageReceived Override this on client side: ${message.message}', ); }; diff --git a/pubspec.yaml b/pubspec.yaml index 1b5e610..ca3bb2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,6 @@ dependencies: sdk: flutter cupertino_icons: ^1.0.2 - flutter_svg: ^2.0.17 firebase_messaging: ^15.1.6 firebase_core: ^3.8.1 flutter_callkit_incoming: ^2.0.4+1 @@ -21,11 +20,11 @@ dependencies: connectivity_plus: ^6.1.0 path_provider: ^2.1.5 flutter_fgbg: ^0.6.0 + provider: ^6.1.2 dev_dependencies: flutter_test: sdk: flutter - provider: ^6.1.2 flutter_masked_text2: ^0.9.1 fluttertoast: ^8.0.9 telnyx_webrtc: @@ -39,5 +38,4 @@ flutter: - assets/audio/incoming_call.mp3 - assets/audio/ringback_tone.mp3 - assets/launcher.png - - assets/telnyx_logo.svg - - assets/telnyx_logo.png \ No newline at end of file + - assets/images/ From 50a313ee9a48589a34a72f7559e07f9777ba2f7a Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 20 Jan 2025 16:50:40 +0000 Subject: [PATCH 09/45] chore: rename viewmodel and use dimensions --- lib/main.dart | 35 ++++++++++----------- lib/view/widgets/header/control_header.dart | 6 ++-- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index b1c469c..ada189c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,7 +13,6 @@ import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/service/notification_service.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; import 'package:telnyx_flutter_webrtc/view/screen/home_screen.dart'; -import 'package:telnyx_flutter_webrtc/view/screen/login_screen.dart'; import 'package:logger/logger.dart'; import 'package:provider/provider.dart'; import 'package:telnyx_webrtc/model/push_notification.dart'; @@ -24,7 +23,7 @@ import 'package:telnyx_flutter_webrtc/utils/theme.dart'; import 'package:telnyx_flutter_webrtc/firebase_options.dart'; final logger = Logger(); -final mainViewModel = TelnyxClientViewModel(); +final txClientViewModel = TelnyxClientViewModel(); const MOCK_USER = ''; const MOCK_PASSWORD = ''; const CALL_MISSED_TIMEOUT = 30; @@ -74,7 +73,7 @@ class AppInitializer { final metadata = event.body['extra']['metadata']; if (metadata == null || (incomingPushCall && fromBackground)) { logger.i('Accepted Call Directly'); - await mainViewModel.accept(); + await txClientViewModel.accept(); /// Reset the incomingPushCall flag and fromBackground flag incomingPushCall = false; @@ -92,7 +91,7 @@ class AppInitializer { final metadata = event.body['extra']['metadata']; if (metadata == null) { logger.i('Decline Call Directly'); - mainViewModel.endCall(); + txClientViewModel.endCall(); } else { logger.i('Received push Call for iOS $metadata'); final data = metadata as Map; @@ -101,11 +100,11 @@ class AppInitializer { } break; case Event.actionCallEnded: - mainViewModel.endCall(); + txClientViewModel.endCall(); logger.i('actionCallEnded :: call ended'); break; case Event.actionCallTimeout: - mainViewModel.endCall(); + txClientViewModel.endCall(); logger.i('Decline Call'); break; case Event.actionCallCallback: @@ -231,7 +230,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { logger.i('iOS notification token :: $token'); } - final credentialConfig = await mainViewModel.getCredentialConfig(); + final credentialConfig = await txClientViewModel.getCredentialConfig(); telnyxClient.handlePushNotification( pushMetaData, credentialConfig, @@ -270,7 +269,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { break; } }); - mainViewModel.updateCallFromPush(true); + txClientViewModel.updateCallFromPush(true); } @pragma('vm:entry-point') @@ -280,16 +279,16 @@ Future main() async { await AppInitializer().initialize(); } - final credentialConfig = await mainViewModel.getCredentialConfig(); + final credentialConfig = await txClientViewModel.getCredentialConfig(); runApp( FGBGNotifier( onEvent: (FGBGType type) => switch (type) { FGBGType.foreground => { logger.i('We are in the foreground, CONNECTING'), // Check if we are from push, if we are do nothing, reconnection will happen there in handlePush. Otherwise connect - if (!mainViewModel.callFromPush) + if (!txClientViewModel.callFromPush) { - mainViewModel.login(credentialConfig), + txClientViewModel.login(credentialConfig), }, }, FGBGType.background => { @@ -297,7 +296,7 @@ Future main() async { 'We are in the background setting fromBackground == true, DISCONNECTING', ), fromBackground = true, - mainViewModel.disconnect(), + txClientViewModel.disconnect(), } }, child: const MyApp(), @@ -306,7 +305,7 @@ Future main() async { } Future handlePush(Map data) async { - mainViewModel.updateCallFromPush(true); + txClientViewModel.updateCallFromPush(true); logger.i('Handle Push Init'); String? token; @@ -321,8 +320,8 @@ Future handlePush(Map data) async { pushMetaData = PushMetaData.fromJson(data); logger.i('iOS notification token :: $token'); } - final credentialConfig = await mainViewModel.getCredentialConfig(); - mainViewModel + final credentialConfig = await txClientViewModel.getCredentialConfig(); + txClientViewModel ..handlePushNotification(pushMetaData!, credentialConfig, null) ..observeResponses(); logger.i('actionCallIncoming :: Received Incoming Call! Handle Push'); @@ -359,7 +358,7 @@ class _MyAppState extends State { logger.i('OnMessage Time :: Notification Message: ${message.sentTime}'); TelnyxClient.setPushMetaData(message.data); NotificationService.showNotification(message); - mainViewModel.updateCallFromPush(true); + txClientViewModel.updateCallFromPush(true); }); FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { logger.i('onMessageOpenedApp :: Notification Message: ${message.data}'); @@ -380,7 +379,7 @@ class _MyAppState extends State { logger.d('getPushData : No data'); } }); - } else if (Platform.isIOS && !mainViewModel.callFromPush) { + } else if (Platform.isIOS && !txClientViewModel.callFromPush) { logger.i('iOS :: connect'); } } catch (e) { @@ -391,7 +390,7 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { return ChangeNotifierProvider( - create: (context) => mainViewModel, + create: (context) => txClientViewModel, child: MaterialApp( title: 'Telnyx WebRTC', theme: AppTheme.lightTheme, diff --git a/lib/view/widgets/header/control_header.dart b/lib/view/widgets/header/control_header.dart index d0ca2d2..2a6791b 100644 --- a/lib/view/widgets/header/control_header.dart +++ b/lib/view/widgets/header/control_header.dart @@ -60,14 +60,14 @@ class SocketConnectivityStatus extends StatelessWidget { return Row( children: [ Container( - width: 10, - height: 10, + width: spacingS, + height: spacingS, decoration: BoxDecoration( color: isConnected ? Colors.green : Colors.red, borderRadius: BorderRadius.circular(5), ), ), - const SizedBox(width: 10), + const SizedBox(width: spacingS), Text(isConnected ? 'Client-ready' : 'Disconnected'), ], ); From 9c2bfa7e38e1bfb03d94b79776eb04b681315f2c Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Mon, 20 Jan 2025 17:16:19 +0000 Subject: [PATCH 10/45] chore: create empty profile_switcher_bottom_sheet --- lib/view/screen/homes_screen.dart | 2 +- .../login/bottom_sheet/profile_switcher_bottom_sheet.dart | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart diff --git a/lib/view/screen/homes_screen.dart b/lib/view/screen/homes_screen.dart index 03ad769..c16769e 100644 --- a/lib/view/screen/homes_screen.dart +++ b/lib/view/screen/homes_screen.dart @@ -40,7 +40,7 @@ class _HomesScreenState extends State { return Scaffold( body: SingleChildScrollView( child: Padding( - padding: const EdgeInsets.all(spacingL), + padding: const EdgeInsets.all(spacingXXL), child: Column( children: [ const ControlHeaders(), diff --git a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart new file mode 100644 index 0000000..e69de29 From bca8a11efa6d3a09cf003803db62d0764206c5ec Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 21 Jan 2025 10:52:17 +0000 Subject: [PATCH 11/45] [Flutter] Profile Bottom Sheet - Created profile model for storing user credentials - Added profile provider with SharedPreferences persistence - Implemented profile switcher bottom sheet UI - Updated login controls to use profiles - Added support for token and credential login Features: - Add profile (Token and SIP Credentials) - Delete profile - Select Profile - Persistent storage using SharedPreferences - Automatic login with selected profile WEBRTC-2449 --- lib/main.dart | 7 +- lib/model/profile_model.dart | 64 ++++ lib/provider/profile_provider.dart | 73 ++++ .../profile_switcher_bottom_sheet.dart | 320 ++++++++++++++++++ lib/view/widgets/login/login_controls.dart | 65 ++-- 5 files changed, 495 insertions(+), 34 deletions(-) create mode 100644 lib/model/profile_model.dart create mode 100644 lib/provider/profile_provider.dart diff --git a/lib/main.dart b/lib/main.dart index ada189c..2db79e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -389,8 +389,11 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (context) => txClientViewModel, + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => txClientViewModel), + ChangeNotifierProvider(create: (context) => ProfileProvider()), + ], child: MaterialApp( title: 'Telnyx WebRTC', theme: AppTheme.lightTheme, diff --git a/lib/model/profile_model.dart b/lib/model/profile_model.dart new file mode 100644 index 0000000..7c2bae6 --- /dev/null +++ b/lib/model/profile_model.dart @@ -0,0 +1,64 @@ +import 'package:telnyx_webrtc/config/telnyx_config.dart'; + +class Profile { + final String name; + final bool isTokenLogin; + final String token; + final String sipUser; + final String sipPassword; + final String sipCallerIDName; + final String sipCallerIDNumber; + + Profile({ + required this.name, + required this.isTokenLogin, + this.token = '', + this.sipUser = '', + this.sipPassword = '', + this.sipCallerIDName = '', + this.sipCallerIDNumber = '', + }); + + factory Profile.fromJson(Map json) { + return Profile( + name: json['name'] as String, + isTokenLogin: json['isTokenLogin'] as bool, + token: json['token'] as String? ?? '', + sipUser: json['sipUser'] as String? ?? '', + sipPassword: json['sipPassword'] as String? ?? '', + sipCallerIDName: json['sipCallerIDName'] as String? ?? '', + sipCallerIDNumber: json['sipCallerIDNumber'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'name': name, + 'isTokenLogin': isTokenLogin, + 'token': token, + 'sipUser': sipUser, + 'sipPassword': sipPassword, + 'sipCallerIDName': sipCallerIDName, + 'sipCallerIDNumber': sipCallerIDNumber, + }; + } + + TelnyxConfig toTelnyxConfig() { + if (isTokenLogin) { + return TokenConfig( + token: token, + sipCallerIDName: sipCallerIDName, + sipCallerIDNumber: sipCallerIDNumber, + debug: false, + ); + } else { + return CredentialConfig( + sipUser: sipUser, + sipPassword: sipPassword, + sipCallerIDName: sipCallerIDName, + sipCallerIDNumber: sipCallerIDNumber, + debug: false, + ); + } + } +} \ No newline at end of file diff --git a/lib/provider/profile_provider.dart b/lib/provider/profile_provider.dart new file mode 100644 index 0000000..9bc801c --- /dev/null +++ b/lib/provider/profile_provider.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences.dart'; +import 'package:telnyx_flutter_webrtc/model/profile_model.dart'; + +class ProfileProvider with ChangeNotifier { + static const String _profilesKey = 'profiles'; + static const String _selectedProfileKey = 'selected_profile'; + List _profiles = []; + Profile? _selectedProfile; + + List get profiles => _profiles; + Profile? get selectedProfile => _selectedProfile; + + ProfileProvider() { + _loadProfiles(); + } + + Future _loadProfiles() async { + final prefs = await SharedPreferences.getInstance(); + final profilesJson = prefs.getStringList(_profilesKey) ?? []; + _profiles = profilesJson + .map((json) => Profile.fromJson(jsonDecode(json))) + .toList(); + + final selectedProfileJson = prefs.getString(_selectedProfileKey); + if (selectedProfileJson != null) { + _selectedProfile = Profile.fromJson(jsonDecode(selectedProfileJson)); + } + notifyListeners(); + } + + Future _saveProfiles() async { + final prefs = await SharedPreferences.getInstance(); + final profilesJson = _profiles + .map((profile) => jsonEncode(profile.toJson())) + .toList(); + await prefs.setStringList(_profilesKey, profilesJson); + + if (_selectedProfile != null) { + await prefs.setString( + _selectedProfileKey, + jsonEncode(_selectedProfile!.toJson()), + ); + } else { + await prefs.remove(_selectedProfileKey); + } + } + + Future addProfile(Profile profile) async { + if (_profiles.any((p) => p.name == profile.name)) { + throw Exception('A profile with this name already exists'); + } + _profiles.add(profile); + await _saveProfiles(); + notifyListeners(); + } + + Future removeProfile(String name) async { + _profiles.removeWhere((profile) => profile.name == name); + if (_selectedProfile?.name == name) { + _selectedProfile = null; + } + await _saveProfiles(); + notifyListeners(); + } + + Future selectProfile(String name) async { + _selectedProfile = _profiles.firstWhere((profile) => profile.name == name); + await _saveProfiles(); + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart index e69de29..8045780 100644 --- a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart +++ b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/model/profile_model.dart'; +import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; + +class ProfileSwitcherBottomSheet extends StatefulWidget { + const ProfileSwitcherBottomSheet({super.key}); + + @override + State createState() => + _ProfileSwitcherBottomSheetState(); +} + +class _ProfileSwitcherBottomSheetState extends State { + bool _isAddingProfile = false; + bool _isTokenLogin = false; + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _tokenController = TextEditingController(); + final _sipUserController = TextEditingController(); + final _sipPasswordController = TextEditingController(); + final _sipCallerIDNameController = TextEditingController(); + final _sipCallerIDNumberController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + _tokenController.dispose(); + _sipUserController.dispose(); + _sipPasswordController.dispose(); + _sipCallerIDNameController.dispose(); + _sipCallerIDNumberController.dispose(); + super.dispose(); + } + + void _resetForm() { + _nameController.clear(); + _tokenController.clear(); + _sipUserController.clear(); + _sipPasswordController.clear(); + _sipCallerIDNameController.clear(); + _sipCallerIDNumberController.clear(); + _isTokenLogin = false; + } + + Widget _buildProfileList() { + return Consumer( + builder: (context, provider, child) { + if (provider.profiles.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('No profiles yet'), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: provider.profiles.length, + itemBuilder: (context, index) { + final profile = provider.profiles[index]; + final isSelected = provider.selectedProfile?.name == profile.name; + + return ListTile( + title: Text(profile.name), + subtitle: Text(profile.isTokenLogin ? 'Token' : 'Credentials'), + selected: isSelected, + selectedTileColor: Theme.of(context).colorScheme.primaryContainer, + leading: Icon( + profile.isTokenLogin ? Icons.key : Icons.person, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).iconTheme.color, + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => provider.removeProfile(profile.name), + ), + onTap: () => provider.selectProfile(profile.name), + ); + }, + ); + }, + ); + } + + Widget _buildAddProfileForm() { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Profile Name', + hintText: 'Enter a name for this profile', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a profile name'; + } + return null; + }, + ), + const SizedBox(height: spacingM), + Row( + children: [ + Switch( + value: _isTokenLogin, + onChanged: (value) { + setState(() { + _isTokenLogin = value; + }); + }, + ), + const SizedBox(width: spacingS), + Text(_isTokenLogin ? 'Token Login' : 'Credential Login'), + ], + ), + const SizedBox(height: spacingM), + if (_isTokenLogin) + TextFormField( + controller: _tokenController, + decoration: const InputDecoration( + labelText: 'Token', + hintText: 'Enter your token', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a token'; + } + return null; + }, + ) + else + Column( + children: [ + TextFormField( + controller: _sipUserController, + decoration: const InputDecoration( + labelText: 'SIP Username', + hintText: 'Enter your SIP username', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a SIP username'; + } + return null; + }, + ), + const SizedBox(height: spacingS), + TextFormField( + controller: _sipPasswordController, + decoration: const InputDecoration( + labelText: 'SIP Password', + hintText: 'Enter your SIP password', + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a SIP password'; + } + return null; + }, + ), + ], + ), + const SizedBox(height: spacingM), + TextFormField( + controller: _sipCallerIDNameController, + decoration: const InputDecoration( + labelText: 'Caller ID Name', + hintText: 'Enter your caller ID name', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a caller ID name'; + } + return null; + }, + ), + const SizedBox(height: spacingS), + TextFormField( + controller: _sipCallerIDNumberController, + decoration: const InputDecoration( + labelText: 'Caller ID Number', + hintText: 'Enter your caller ID number', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a caller ID number'; + } + return null; + }, + ), + const SizedBox(height: spacingL), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + setState(() { + _isAddingProfile = false; + _resetForm(); + }); + }, + child: const Text('Cancel'), + ), + const SizedBox(width: spacingM), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + final profile = Profile( + name: _nameController.text, + isTokenLogin: _isTokenLogin, + token: _tokenController.text, + sipUser: _sipUserController.text, + sipPassword: _sipPasswordController.text, + sipCallerIDName: _sipCallerIDNameController.text, + sipCallerIDNumber: _sipCallerIDNumberController.text, + ); + + try { + context.read().addProfile(profile); + setState(() { + _isAddingProfile = false; + _resetForm(); + }); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } + } + }, + child: const Text('Save'), + ), + ], + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + const Text( + 'Existing Profiles', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + if (!_isAddingProfile) + TextButton.icon( + onPressed: () { + setState(() { + _isAddingProfile = true; + }); + }, + icon: const Icon(Icons.add), + label: const Text('Add new profile'), + ), + ], + ), + const SizedBox(height: spacingM), + if (_isAddingProfile) + _buildAddProfileForm() + else + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildProfileList(), + const SizedBox(height: spacingL), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + const SizedBox(width: spacingM), + ElevatedButton( + onPressed: context.watch().selectedProfile != null + ? () => Navigator.pop(context) + : null, + child: const Text('Confirm'), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/view/widgets/login/login_controls.dart b/lib/view/widgets/login/login_controls.dart index 09c3df0..6d173b4 100644 --- a/lib/view/widgets/login/login_controls.dart +++ b/lib/view/widgets/login/login_controls.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; -import 'package:telnyx_webrtc/config/telnyx_config.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart'; class LoginControls extends StatefulWidget { const LoginControls({super.key}); @@ -12,38 +13,35 @@ class LoginControls extends StatefulWidget { } class _LoginControlsState extends State { - bool isTokenLogin = false; + void _showProfileSwitcher() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: const ProfileSwitcherBottomSheet(), + ), + ); + } @override Widget build(BuildContext context) { + final profileProvider = context.watch(); + final selectedProfile = profileProvider.selectedProfile; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Token Login', style: Theme.of(context).textTheme.labelMedium), - const SizedBox(height: spacingS), - Row( - children: [ - Switch( - value: isTokenLogin, - onChanged: (value) { - setState(() { - isTokenLogin = value; - }); - }, - ), - SizedBox(width: spacingS), - Text(isTokenLogin ? 'On' : 'Off'), - ], - ), - const SizedBox(height: spacingXL), Text('Profile', style: Theme.of(context).textTheme.labelMedium), const SizedBox(height: spacingS), Row( children: [ - Text('User'), + Text(selectedProfile?.name ?? 'No profile selected'), SizedBox(width: spacingS), TextButton( - onPressed: () {}, + onPressed: _showProfileSwitcher, child: const Text('Switch Profile'), ), ], @@ -51,17 +49,20 @@ class _LoginControlsState extends State { SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () { - Provider.of(context, listen: false).login( - CredentialConfig( - sipUser: 'placeholder', - sipPassword: 'placeholder', - sipCallerIDName: 'placeholder', - sipCallerIDNumber: 'placeholder', - debug: false, - ), - ); - }, + onPressed: selectedProfile != null + ? () { + final viewModel = context.read(); + if (selectedProfile.isTokenLogin) { + viewModel.loginWithToken( + selectedProfile.toTelnyxConfig() as TokenConfig, + ); + } else { + viewModel.login( + selectedProfile.toTelnyxConfig() as CredentialConfig, + ); + } + } + : null, child: const Text('Connect'), ), ), From 0a91f3c0adb1df7cb6b7300eb3356aa6c625d9bb Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 21 Jan 2025 11:16:35 +0000 Subject: [PATCH 12/45] fix: adjust imports and references --- lib/main.dart | 1 + lib/model/profile_model.dart | 4 ++-- lib/provider/profile_provider.dart | 2 +- lib/view/widgets/login/login_controls.dart | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 2db79e0..67e28b9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; +import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; import 'package:telnyx_flutter_webrtc/view/screen/homes_screen.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/service/notification_service.dart'; diff --git a/lib/model/profile_model.dart b/lib/model/profile_model.dart index 7c2bae6..bcca9ef 100644 --- a/lib/model/profile_model.dart +++ b/lib/model/profile_model.dart @@ -43,10 +43,10 @@ class Profile { }; } - TelnyxConfig toTelnyxConfig() { + Config toTelnyxConfig() { if (isTokenLogin) { return TokenConfig( - token: token, + sipToken: token, sipCallerIDName: sipCallerIDName, sipCallerIDNumber: sipCallerIDNumber, debug: false, diff --git a/lib/provider/profile_provider.dart b/lib/provider/profile_provider.dart index 9bc801c..cfda448 100644 --- a/lib/provider/profile_provider.dart +++ b/lib/provider/profile_provider.dart @@ -1,6 +1,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:shared_preferences.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:telnyx_flutter_webrtc/model/profile_model.dart'; class ProfileProvider with ChangeNotifier { diff --git a/lib/view/widgets/login/login_controls.dart b/lib/view/widgets/login/login_controls.dart index 6d173b4..6b6b98b 100644 --- a/lib/view/widgets/login/login_controls.dart +++ b/lib/view/widgets/login/login_controls.dart @@ -4,6 +4,7 @@ import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart'; +import 'package:telnyx_webrtc/config/telnyx_config.dart'; class LoginControls extends StatefulWidget { const LoginControls({super.key}); From e70ec46b3610fad96efc255352397ebefcab9726 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 21 Jan 2025 12:30:43 +0000 Subject: [PATCH 13/45] feat: adjust styling for bottom sheet to more closely align with figma --- lib/model/profile_model.dart | 4 - lib/provider/profile_provider.dart | 8 +- lib/utils/theme.dart | 3 +- .../profile_switcher_bottom_sheet.dart | 250 +++++++++--------- lib/view/widgets/login/login_controls.dart | 3 +- 5 files changed, 136 insertions(+), 132 deletions(-) diff --git a/lib/model/profile_model.dart b/lib/model/profile_model.dart index bcca9ef..6f1e221 100644 --- a/lib/model/profile_model.dart +++ b/lib/model/profile_model.dart @@ -1,7 +1,6 @@ import 'package:telnyx_webrtc/config/telnyx_config.dart'; class Profile { - final String name; final bool isTokenLogin; final String token; final String sipUser; @@ -10,7 +9,6 @@ class Profile { final String sipCallerIDNumber; Profile({ - required this.name, required this.isTokenLogin, this.token = '', this.sipUser = '', @@ -21,7 +19,6 @@ class Profile { factory Profile.fromJson(Map json) { return Profile( - name: json['name'] as String, isTokenLogin: json['isTokenLogin'] as bool, token: json['token'] as String? ?? '', sipUser: json['sipUser'] as String? ?? '', @@ -33,7 +30,6 @@ class Profile { Map toJson() { return { - 'name': name, 'isTokenLogin': isTokenLogin, 'token': token, 'sipUser': sipUser, diff --git a/lib/provider/profile_provider.dart b/lib/provider/profile_provider.dart index cfda448..ee84d75 100644 --- a/lib/provider/profile_provider.dart +++ b/lib/provider/profile_provider.dart @@ -48,7 +48,7 @@ class ProfileProvider with ChangeNotifier { } Future addProfile(Profile profile) async { - if (_profiles.any((p) => p.name == profile.name)) { + if (_profiles.any((p) => p.sipCallerIDName == profile.sipCallerIDName)) { throw Exception('A profile with this name already exists'); } _profiles.add(profile); @@ -57,8 +57,8 @@ class ProfileProvider with ChangeNotifier { } Future removeProfile(String name) async { - _profiles.removeWhere((profile) => profile.name == name); - if (_selectedProfile?.name == name) { + _profiles.removeWhere((profile) => profile.sipCallerIDName == name); + if (_selectedProfile?.sipCallerIDName == name) { _selectedProfile = null; } await _saveProfiles(); @@ -66,7 +66,7 @@ class ProfileProvider with ChangeNotifier { } Future selectProfile(String name) async { - _selectedProfile = _profiles.firstWhere((profile) => profile.name == name); + _selectedProfile = _profiles.firstWhere((profile) => profile.sipCallerIDName == name); await _saveProfiles(); notifyListeners(); } diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index fed799d..e29c208 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; // Theme colors const surfaceColor = Color(0xFFFEFDF5); +const darkSurfaceColor = Color(0xFFf6f4e6); const primaryColor = Colors.black; const secondaryColor = Colors.white; const disabledColor = Color(0xFF808080); // Gray for disabled state @@ -17,7 +18,7 @@ class AppTheme { onPrimary: secondaryColor, secondary: secondaryColor, onSecondary: primaryColor, - surface: surfaceColor, + surface: darkSurfaceColor, ), textTheme: TextTheme( diff --git a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart index 8045780..d36a815 100644 --- a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart +++ b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart @@ -12,39 +12,91 @@ class ProfileSwitcherBottomSheet extends StatefulWidget { _ProfileSwitcherBottomSheetState(); } -class _ProfileSwitcherBottomSheetState extends State { +class _ProfileSwitcherBottomSheetState + extends State { bool _isAddingProfile = false; - bool _isTokenLogin = false; - final _formKey = GlobalKey(); - final _nameController = TextEditingController(); - final _tokenController = TextEditingController(); - final _sipUserController = TextEditingController(); - final _sipPasswordController = TextEditingController(); - final _sipCallerIDNameController = TextEditingController(); - final _sipCallerIDNumberController = TextEditingController(); @override - void dispose() { - _nameController.dispose(); - _tokenController.dispose(); - _sipUserController.dispose(); - _sipPasswordController.dispose(); - _sipCallerIDNameController.dispose(); - _sipCallerIDNumberController.dispose(); - super.dispose(); + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.80, + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + const Text( + 'Existing Profiles', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + if (!_isAddingProfile) + TextButton.icon( + onPressed: () { + setState(() { + _isAddingProfile = true; + }); + }, + icon: const Icon(Icons.add), + label: const Text('Add new profile'), + ), + ], + ), + const SizedBox(height: spacingM), + if (_isAddingProfile) + const AddProfileForm() + else + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ProfileList(), + const SizedBox(height: spacingL), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + const SizedBox(width: spacingM), + ElevatedButton( + onPressed: + context.watch().selectedProfile != + null + ? () => Navigator.pop(context) + : null, + child: const Text('Confirm'), + ), + ], + ), + ], + ), + ), + ], + ), + ); } +} - void _resetForm() { - _nameController.clear(); - _tokenController.clear(); - _sipUserController.clear(); - _sipPasswordController.clear(); - _sipCallerIDNameController.clear(); - _sipCallerIDNumberController.clear(); - _isTokenLogin = false; - } +class ProfileList extends StatelessWidget { + const ProfileList({Key? key}) : super(key: key); - Widget _buildProfileList() { + @override + Widget build(BuildContext context) { return Consumer( builder: (context, provider, child) { if (provider.profiles.isEmpty) { @@ -61,13 +113,14 @@ class _ProfileSwitcherBottomSheetState extends State itemCount: provider.profiles.length, itemBuilder: (context, index) { final profile = provider.profiles[index]; - final isSelected = provider.selectedProfile?.name == profile.name; + final isSelected = provider.selectedProfile?.sipCallerIDName == + profile.sipCallerIDName; return ListTile( - title: Text(profile.name), + title: Text(profile.sipCallerIDName), subtitle: Text(profile.isTokenLogin ? 'Token' : 'Credentials'), selected: isSelected, - selectedTileColor: Theme.of(context).colorScheme.primaryContainer, + selectedTileColor: Theme.of(context).colorScheme.surface, leading: Icon( profile.isTokenLogin ? Icons.key : Icons.person, color: isSelected @@ -76,36 +129,64 @@ class _ProfileSwitcherBottomSheetState extends State ), trailing: IconButton( icon: const Icon(Icons.delete), - onPressed: () => provider.removeProfile(profile.name), + onPressed: () => + provider.removeProfile(profile.sipCallerIDName), ), - onTap: () => provider.selectProfile(profile.name), + onTap: () => provider.selectProfile(profile.sipCallerIDName), ); }, ); }, ); } +} + +class AddProfileForm extends StatefulWidget { + const AddProfileForm({Key? key}) : super(key: key); + + @override + _AddProfileFormState createState() => _AddProfileFormState(); +} + +class _AddProfileFormState extends State { + bool _isTokenLogin = false; + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _tokenController = TextEditingController(); + final _sipUserController = TextEditingController(); + final _sipPasswordController = TextEditingController(); + final _sipCallerIDNameController = TextEditingController(); + final _sipCallerIDNumberController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + _tokenController.dispose(); + _sipUserController.dispose(); + _sipPasswordController.dispose(); + _sipCallerIDNameController.dispose(); + _sipCallerIDNumberController.dispose(); + super.dispose(); + } - Widget _buildAddProfileForm() { + void _resetForm() { + _nameController.clear(); + _tokenController.clear(); + _sipUserController.clear(); + _sipPasswordController.clear(); + _sipCallerIDNameController.clear(); + _sipCallerIDNumberController.clear(); + _isTokenLogin = false; + } + + @override + Widget build(BuildContext context) { return Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: 'Profile Name', - hintText: 'Enter a name for this profile', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a profile name'; - } - return null; - }, - ), const SizedBox(height: spacingM), Row( children: [ @@ -204,7 +285,6 @@ class _ProfileSwitcherBottomSheetState extends State TextButton( onPressed: () { setState(() { - _isAddingProfile = false; _resetForm(); }); }, @@ -215,7 +295,6 @@ class _ProfileSwitcherBottomSheetState extends State onPressed: () { if (_formKey.currentState!.validate()) { final profile = Profile( - name: _nameController.text, isTokenLogin: _isTokenLogin, token: _tokenController.text, sipUser: _sipUserController.text, @@ -227,7 +306,6 @@ class _ProfileSwitcherBottomSheetState extends State try { context.read().addProfile(profile); setState(() { - _isAddingProfile = false; _resetForm(); }); } catch (e) { @@ -245,76 +323,4 @@ class _ProfileSwitcherBottomSheetState extends State ), ); } - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - const Text( - 'Existing Profiles', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - if (!_isAddingProfile) - TextButton.icon( - onPressed: () { - setState(() { - _isAddingProfile = true; - }); - }, - icon: const Icon(Icons.add), - label: const Text('Add new profile'), - ), - ], - ), - const SizedBox(height: spacingM), - if (_isAddingProfile) - _buildAddProfileForm() - else - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildProfileList(), - const SizedBox(height: spacingL), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - const SizedBox(width: spacingM), - ElevatedButton( - onPressed: context.watch().selectedProfile != null - ? () => Navigator.pop(context) - : null, - child: const Text('Confirm'), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } -} \ No newline at end of file +} diff --git a/lib/view/widgets/login/login_controls.dart b/lib/view/widgets/login/login_controls.dart index 6b6b98b..4d823bd 100644 --- a/lib/view/widgets/login/login_controls.dart +++ b/lib/view/widgets/login/login_controls.dart @@ -17,6 +17,7 @@ class _LoginControlsState extends State { void _showProfileSwitcher() { showModalBottomSheet( context: context, + backgroundColor: Colors.white, isScrollControlled: true, builder: (context) => Padding( padding: EdgeInsets.only( @@ -39,7 +40,7 @@ class _LoginControlsState extends State { const SizedBox(height: spacingS), Row( children: [ - Text(selectedProfile?.name ?? 'No profile selected'), + Text(selectedProfile?.sipCallerIDName ?? 'No profile selected'), SizedBox(width: spacingS), TextButton( onPressed: _showProfileSwitcher, From 458fc1fef574f2b3ff74836b7ba70ee0236d4efa Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 21 Jan 2025 14:39:54 +0000 Subject: [PATCH 14/45] feat: separate out widgets instead of widget return methods (this is an anti pattern) --- .../login/bottom_sheet/add_profile_form.dart | 195 +++++++++++ .../login/bottom_sheet/profile_list.dart | 52 +++ .../profile_switcher_bottom_sheet.dart | 309 +++--------------- 3 files changed, 294 insertions(+), 262 deletions(-) create mode 100644 lib/view/widgets/login/bottom_sheet/add_profile_form.dart create mode 100644 lib/view/widgets/login/bottom_sheet/profile_list.dart diff --git a/lib/view/widgets/login/bottom_sheet/add_profile_form.dart b/lib/view/widgets/login/bottom_sheet/add_profile_form.dart new file mode 100644 index 0000000..cdb3fee --- /dev/null +++ b/lib/view/widgets/login/bottom_sheet/add_profile_form.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/model/profile_model.dart'; +import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; + +class AddProfileForm extends StatefulWidget { + final VoidCallback onCancelPressed; + + const AddProfileForm({Key? key, required this.onCancelPressed}) + : super(key: key); + + @override + _AddProfileFormState createState() => _AddProfileFormState(); +} + +class _AddProfileFormState extends State { + bool _isTokenLogin = false; + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _tokenController = TextEditingController(); + final _sipUserController = TextEditingController(); + final _sipPasswordController = TextEditingController(); + final _sipCallerIDNameController = TextEditingController(); + final _sipCallerIDNumberController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + _tokenController.dispose(); + _sipUserController.dispose(); + _sipPasswordController.dispose(); + _sipCallerIDNameController.dispose(); + _sipCallerIDNumberController.dispose(); + super.dispose(); + } + + void _resetForm() { + _nameController.clear(); + _tokenController.clear(); + _sipUserController.clear(); + _sipPasswordController.clear(); + _sipCallerIDNameController.clear(); + _sipCallerIDNumberController.clear(); + _isTokenLogin = false; + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: spacingM), + Row( + children: [ + Switch( + value: _isTokenLogin, + onChanged: (value) { + setState(() { + _isTokenLogin = value; + }); + }, + ), + const SizedBox(width: spacingS), + Text(_isTokenLogin ? 'Token Login' : 'Credential Login'), + ], + ), + const SizedBox(height: spacingM), + if (_isTokenLogin) + TextFormField( + controller: _tokenController, + decoration: const InputDecoration( + labelText: 'Token', + hintText: 'Enter your token', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a token'; + } + return null; + }, + ) + else + Column( + children: [ + TextFormField( + controller: _sipUserController, + decoration: const InputDecoration( + labelText: 'SIP Username', + hintText: 'Enter your SIP username', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a SIP username'; + } + return null; + }, + ), + const SizedBox(height: spacingS), + TextFormField( + controller: _sipPasswordController, + decoration: const InputDecoration( + labelText: 'SIP Password', + hintText: 'Enter your SIP password', + ), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a SIP password'; + } + return null; + }, + ), + ], + ), + const SizedBox(height: spacingM), + TextFormField( + controller: _sipCallerIDNameController, + decoration: const InputDecoration( + labelText: 'Caller ID Name', + hintText: 'Enter your caller ID name', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a caller ID name'; + } + return null; + }, + ), + const SizedBox(height: spacingS), + TextFormField( + controller: _sipCallerIDNumberController, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'Caller ID Number', + hintText: 'Enter your caller ID number', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a caller ID number'; + } + return null; + }, + ), + const SizedBox(height: spacingL), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + setState(() { + _resetForm(); + }); + widget.onCancelPressed(); + }, + child: const Text('Cancel'), + ), + const SizedBox(width: spacingM), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + final profile = Profile( + isTokenLogin: _isTokenLogin, + token: _tokenController.text, + sipUser: _sipUserController.text, + sipPassword: _sipPasswordController.text, + sipCallerIDName: _sipCallerIDNameController.text, + sipCallerIDNumber: _sipCallerIDNumberController.text, + ); + + try { + context.read().addProfile(profile); + setState(() { + _resetForm(); + }); + widget.onCancelPressed(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } + } + }, + child: const Text('Save'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/view/widgets/login/bottom_sheet/profile_list.dart b/lib/view/widgets/login/bottom_sheet/profile_list.dart new file mode 100644 index 0000000..dfbde5b --- /dev/null +++ b/lib/view/widgets/login/bottom_sheet/profile_list.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; + +class ProfileList extends StatelessWidget { + const ProfileList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + if (provider.profiles.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('No profiles yet'), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: provider.profiles.length, + itemBuilder: (context, index) { + final profile = provider.profiles[index]; + final isSelected = provider.selectedProfile?.sipCallerIDName == + profile.sipCallerIDName; + + return ListTile( + title: Text(profile.sipCallerIDName), + subtitle: Text(profile.isTokenLogin ? 'Token' : 'Credentials'), + selected: isSelected, + selectedTileColor: Theme.of(context).colorScheme.surface, + leading: Icon( + profile.isTokenLogin ? Icons.key : Icons.person, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).iconTheme.color, + ), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => + provider.removeProfile(profile.sipCallerIDName), + ), + onTap: () => provider.selectProfile(profile.sipCallerIDName), + ); + }, + ); + }, + ); + } +} diff --git a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart index d36a815..6d8dd32 100644 --- a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart +++ b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:telnyx_flutter_webrtc/model/profile_model.dart'; import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/login/bottom_sheet/add_profile_form.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/login/bottom_sheet/profile_list.dart'; class ProfileSwitcherBottomSheet extends StatefulWidget { const ProfileSwitcherBottomSheet({super.key}); @@ -25,39 +26,56 @@ class _ProfileSwitcherBottomSheetState mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - const Text( - 'Existing Profiles', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Existing Profiles', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, ), - ], - ), - if (!_isAddingProfile) - TextButton.icon( - onPressed: () { - setState(() { - _isAddingProfile = true; - }); - }, - icon: const Icon(Icons.add), - label: const Text('Add new profile'), ), - ], + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), ), + if (!_isAddingProfile) + ElevatedButton.icon( + onPressed: () { + setState(() { + _isAddingProfile = true; + }); + }, + icon: const Icon(Icons.add, color: Colors.black), + label: const Text( + 'Add new profile', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.0), + ), + ), + ), const SizedBox(height: spacingM), if (_isAddingProfile) - const AddProfileForm() + AddProfileForm( + onCancelPressed: () => { + setState(() { + _isAddingProfile = false; + }), + }, + ) else Flexible( child: Column( @@ -91,236 +109,3 @@ class _ProfileSwitcherBottomSheetState ); } } - -class ProfileList extends StatelessWidget { - const ProfileList({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, provider, child) { - if (provider.profiles.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(16.0), - child: Text('No profiles yet'), - ), - ); - } - - return ListView.builder( - shrinkWrap: true, - itemCount: provider.profiles.length, - itemBuilder: (context, index) { - final profile = provider.profiles[index]; - final isSelected = provider.selectedProfile?.sipCallerIDName == - profile.sipCallerIDName; - - return ListTile( - title: Text(profile.sipCallerIDName), - subtitle: Text(profile.isTokenLogin ? 'Token' : 'Credentials'), - selected: isSelected, - selectedTileColor: Theme.of(context).colorScheme.surface, - leading: Icon( - profile.isTokenLogin ? Icons.key : Icons.person, - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).iconTheme.color, - ), - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () => - provider.removeProfile(profile.sipCallerIDName), - ), - onTap: () => provider.selectProfile(profile.sipCallerIDName), - ); - }, - ); - }, - ); - } -} - -class AddProfileForm extends StatefulWidget { - const AddProfileForm({Key? key}) : super(key: key); - - @override - _AddProfileFormState createState() => _AddProfileFormState(); -} - -class _AddProfileFormState extends State { - bool _isTokenLogin = false; - final _formKey = GlobalKey(); - final _nameController = TextEditingController(); - final _tokenController = TextEditingController(); - final _sipUserController = TextEditingController(); - final _sipPasswordController = TextEditingController(); - final _sipCallerIDNameController = TextEditingController(); - final _sipCallerIDNumberController = TextEditingController(); - - @override - void dispose() { - _nameController.dispose(); - _tokenController.dispose(); - _sipUserController.dispose(); - _sipPasswordController.dispose(); - _sipCallerIDNameController.dispose(); - _sipCallerIDNumberController.dispose(); - super.dispose(); - } - - void _resetForm() { - _nameController.clear(); - _tokenController.clear(); - _sipUserController.clear(); - _sipPasswordController.clear(); - _sipCallerIDNameController.clear(); - _sipCallerIDNumberController.clear(); - _isTokenLogin = false; - } - - @override - Widget build(BuildContext context) { - return Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: spacingM), - Row( - children: [ - Switch( - value: _isTokenLogin, - onChanged: (value) { - setState(() { - _isTokenLogin = value; - }); - }, - ), - const SizedBox(width: spacingS), - Text(_isTokenLogin ? 'Token Login' : 'Credential Login'), - ], - ), - const SizedBox(height: spacingM), - if (_isTokenLogin) - TextFormField( - controller: _tokenController, - decoration: const InputDecoration( - labelText: 'Token', - hintText: 'Enter your token', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a token'; - } - return null; - }, - ) - else - Column( - children: [ - TextFormField( - controller: _sipUserController, - decoration: const InputDecoration( - labelText: 'SIP Username', - hintText: 'Enter your SIP username', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a SIP username'; - } - return null; - }, - ), - const SizedBox(height: spacingS), - TextFormField( - controller: _sipPasswordController, - decoration: const InputDecoration( - labelText: 'SIP Password', - hintText: 'Enter your SIP password', - ), - obscureText: true, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a SIP password'; - } - return null; - }, - ), - ], - ), - const SizedBox(height: spacingM), - TextFormField( - controller: _sipCallerIDNameController, - decoration: const InputDecoration( - labelText: 'Caller ID Name', - hintText: 'Enter your caller ID name', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a caller ID name'; - } - return null; - }, - ), - const SizedBox(height: spacingS), - TextFormField( - controller: _sipCallerIDNumberController, - decoration: const InputDecoration( - labelText: 'Caller ID Number', - hintText: 'Enter your caller ID number', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a caller ID number'; - } - return null; - }, - ), - const SizedBox(height: spacingL), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () { - setState(() { - _resetForm(); - }); - }, - child: const Text('Cancel'), - ), - const SizedBox(width: spacingM), - ElevatedButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - final profile = Profile( - isTokenLogin: _isTokenLogin, - token: _tokenController.text, - sipUser: _sipUserController.text, - sipPassword: _sipPasswordController.text, - sipCallerIDName: _sipCallerIDNameController.text, - sipCallerIDNumber: _sipCallerIDNumberController.text, - ); - - try { - context.read().addProfile(profile); - setState(() { - _resetForm(); - }); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); - } - } - }, - child: const Text('Save'), - ), - ], - ), - ], - ), - ); - } -} From ead332c204d49e52ebbb6331c303a6614f038506 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 21 Jan 2025 15:08:37 +0000 Subject: [PATCH 15/45] feat: allow edit of existing profiles --- .../login/bottom_sheet/add_profile_form.dart | 17 ++++++++++++- .../login/bottom_sheet/profile_list.dart | 25 ++++++++++++++----- .../profile_switcher_bottom_sheet.dart | 12 ++++++++- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/lib/view/widgets/login/bottom_sheet/add_profile_form.dart b/lib/view/widgets/login/bottom_sheet/add_profile_form.dart index cdb3fee..476dec8 100644 --- a/lib/view/widgets/login/bottom_sheet/add_profile_form.dart +++ b/lib/view/widgets/login/bottom_sheet/add_profile_form.dart @@ -5,9 +5,10 @@ import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; class AddProfileForm extends StatefulWidget { + final Profile? existingProfile; final VoidCallback onCancelPressed; - const AddProfileForm({Key? key, required this.onCancelPressed}) + const AddProfileForm({Key? key, required this.onCancelPressed, this.existingProfile}) : super(key: key); @override @@ -24,6 +25,20 @@ class _AddProfileFormState extends State { final _sipCallerIDNameController = TextEditingController(); final _sipCallerIDNumberController = TextEditingController(); + @override + void initState() { + super.initState(); + if (widget.existingProfile != null) { + final profile = widget.existingProfile!; + _isTokenLogin = profile.isTokenLogin; + _tokenController.text = profile.token; + _sipUserController.text = profile.sipUser; + _sipPasswordController.text = profile.sipPassword; + _sipCallerIDNameController.text = profile.sipCallerIDName; + _sipCallerIDNumberController.text = profile.sipCallerIDNumber; + } + } + @override void dispose() { _nameController.dispose(); diff --git a/lib/view/widgets/login/bottom_sheet/profile_list.dart b/lib/view/widgets/login/bottom_sheet/profile_list.dart index dfbde5b..7860a47 100644 --- a/lib/view/widgets/login/bottom_sheet/profile_list.dart +++ b/lib/view/widgets/login/bottom_sheet/profile_list.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/model/profile_model.dart'; import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; class ProfileList extends StatelessWidget { - const ProfileList({Key? key}) : super(key: key); + final void Function(Profile) onProfileEditSelected; + const ProfileList({Key? key, required this.onProfileEditSelected}) + : super(key: key); @override Widget build(BuildContext context) { @@ -26,7 +29,7 @@ class ProfileList extends StatelessWidget { final isSelected = provider.selectedProfile?.sipCallerIDName == profile.sipCallerIDName; - return ListTile( + return ListTile( title: Text(profile.sipCallerIDName), subtitle: Text(profile.isTokenLogin ? 'Token' : 'Credentials'), selected: isSelected, @@ -37,10 +40,20 @@ class ProfileList extends StatelessWidget { ? Theme.of(context).colorScheme.primary : Theme.of(context).iconTheme.color, ), - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () => - provider.removeProfile(profile.sipCallerIDName), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => + onProfileEditSelected(profile), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () => + provider.removeProfile(profile.sipCallerIDName), + ), + ], ), onTap: () => provider.selectProfile(profile.sipCallerIDName), ); diff --git a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart index 6d8dd32..e0f9a44 100644 --- a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart +++ b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/model/profile_model.dart'; import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/login/bottom_sheet/add_profile_form.dart'; @@ -16,6 +17,7 @@ class ProfileSwitcherBottomSheet extends StatefulWidget { class _ProfileSwitcherBottomSheetState extends State { bool _isAddingProfile = false; + Profile? _selectedProfile; @override Widget build(BuildContext context) { @@ -75,13 +77,21 @@ class _ProfileSwitcherBottomSheetState _isAddingProfile = false; }), }, + existingProfile: _selectedProfile, ) else Flexible( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const ProfileList(), + ProfileList( + onProfileEditSelected: (profile) => { + setState(() { + _selectedProfile = profile; + _isAddingProfile = true; + }), + }, + ), const SizedBox(height: spacingL), Row( mainAxisAlignment: MainAxisAlignment.end, From 59214fb858bc21065667c37f5683c8a3c51303bf Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Wed, 22 Jan 2025 12:03:04 +0000 Subject: [PATCH 16/45] feat: loading dialog feedback as connecting --- .../profile_switcher_bottom_sheet.dart | 2 +- lib/view/widgets/login/login_controls.dart | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart index e0f9a44..65df6bd 100644 --- a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart +++ b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart @@ -59,7 +59,7 @@ class _ProfileSwitcherBottomSheetState 'Add new profile', style: TextStyle( color: Colors.black, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w400, ), ), style: ElevatedButton.styleFrom( diff --git a/lib/view/widgets/login/login_controls.dart b/lib/view/widgets/login/login_controls.dart index 4d823bd..592742f 100644 --- a/lib/view/widgets/login/login_controls.dart +++ b/lib/view/widgets/login/login_controls.dart @@ -37,7 +37,7 @@ class _LoginControlsState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Profile', style: Theme.of(context).textTheme.labelMedium), - const SizedBox(height: spacingS), + const SizedBox(height: spacingXS), Row( children: [ Text(selectedProfile?.sipCallerIDName ?? 'No profile selected'), @@ -48,6 +48,7 @@ class _LoginControlsState extends State { ), ], ), + const SizedBox(height: spacingS), SizedBox( width: double.infinity, child: ElevatedButton( @@ -65,7 +66,22 @@ class _LoginControlsState extends State { } } : null, - child: const Text('Connect'), + child: Consumer( + builder: (context, provider, child) { + if (provider.loggingIn) { + return SizedBox( + width: spacingXL, + height: spacingXL, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ); + } else { + return const Text('Connect'); + } + }, + ), ), ), ], From 3e7f2e2a49b63964662f994b5b3bbb6679a727b9 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 28 Jan 2025 12:05:48 +0000 Subject: [PATCH 17/45] feat: [WIP] implementing call controls --- assets/icons/green_call.svg | 18 ++ assets/icons/loud_speaker.svg | 19 ++ assets/icons/mute.svg | 18 ++ assets/icons/red_decline.svg | 18 ++ lib/utils/asset_paths.dart | 8 + lib/utils/dimensions.dart | 2 + lib/utils/theme.dart | 8 + lib/view/screen/homes_screen.dart | 4 +- .../widgets/call_controls/call_controls.dart | 251 ++++++++++++++++++ pubspec.yaml | 2 + 10 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 assets/icons/green_call.svg create mode 100644 assets/icons/loud_speaker.svg create mode 100644 assets/icons/mute.svg create mode 100644 assets/icons/red_decline.svg create mode 100644 lib/view/widgets/call_controls/call_controls.dart diff --git a/assets/icons/green_call.svg b/assets/icons/green_call.svg new file mode 100644 index 0000000..292142f --- /dev/null +++ b/assets/icons/green_call.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/loud_speaker.svg b/assets/icons/loud_speaker.svg new file mode 100644 index 0000000..e627a0a --- /dev/null +++ b/assets/icons/loud_speaker.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/mute.svg b/assets/icons/mute.svg new file mode 100644 index 0000000..6109000 --- /dev/null +++ b/assets/icons/mute.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/red_decline.svg b/assets/icons/red_decline.svg new file mode 100644 index 0000000..48876da --- /dev/null +++ b/assets/icons/red_decline.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/lib/utils/asset_paths.dart b/lib/utils/asset_paths.dart index 5ef30f9..713d382 100644 --- a/lib/utils/asset_paths.dart +++ b/lib/utils/asset_paths.dart @@ -1 +1,9 @@ const logo_path = 'assets/images/telnyx_logo.png'; + +// SVG Icons +const green_call_icon = 'assets/icons/green_call.svg'; +const red_decline_icon = 'assets/icons/red_decline.svg'; +const mute_icon = 'assets/icons/mute.svg'; +const loudspeaker_icon = 'assets/icons/loudspeaker.svg'; + + diff --git a/lib/utils/dimensions.dart b/lib/utils/dimensions.dart index 69708f1..77b2d60 100644 --- a/lib/utils/dimensions.dart +++ b/lib/utils/dimensions.dart @@ -15,3 +15,5 @@ const fontSizeM = 16.0; const fontSizeL = 18.0; const fontSizeXL = 24.0; const fontSizeXXL = 32.0; + +const iconSize = 64.0; diff --git a/lib/utils/theme.dart b/lib/utils/theme.dart index e29c208..51eca4a 100644 --- a/lib/utils/theme.dart +++ b/lib/utils/theme.dart @@ -8,6 +8,9 @@ const secondaryColor = Colors.white; const disabledColor = Color(0xFF808080); // Gray for disabled state const telnyx_soft_black = Color(0xFF272727); const telnyx_grey = Color(0xFF525252); +const telnyx_green = Color(0xFF00E3AA); +const active_text_field_color = Color(0xFF008563); +const call_control_color = Color(0xFFF5F3E4); class AppTheme { static ThemeData get lightTheme { @@ -37,6 +40,11 @@ class AppTheme { fontWeight: FontWeight.w400, color: telnyx_grey, ), + labelSmall: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: telnyx_grey, + ), ), // Input decoration theme diff --git a/lib/view/screen/homes_screen.dart b/lib/view/screen/homes_screen.dart index c16769e..eaf32d2 100644 --- a/lib/view/screen/homes_screen.dart +++ b/lib/view/screen/homes_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/call_controls.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/header/control_header.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/invitation_widget.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/login/login_controls.dart'; @@ -47,7 +48,8 @@ class _HomesScreenState extends State { const SizedBox(height: spacingS), if (clientState == CallStateStatus.disconnected) const LoginControls(), - if (clientState == CallStateStatus.idle) Text('Destination'), + if (clientState == CallStateStatus.idle) + const CallControls(), if (clientState == CallStateStatus.ringing) const Text('Ringing'), if (clientState == CallStateStatus.ongoingInvitation) const InvitationWidget( diff --git a/lib/view/widgets/call_controls/call_controls.dart b/lib/view/widgets/call_controls/call_controls.dart new file mode 100644 index 0000000..10cd4de --- /dev/null +++ b/lib/view/widgets/call_controls/call_controls.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/utils/asset_paths.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; +import 'package:telnyx_flutter_webrtc/utils/theme.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; + +class CallControls extends StatefulWidget { + const CallControls({super.key}); + + @override + State createState() => _CallControlsState(); +} + +class _CallControlsState extends State { + final _destinationController = TextEditingController(); + + @override + void dispose() { + _destinationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final clientState = context.select( + (txClient) => txClient.callState, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Destination', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: spacingXS), + Padding( + padding: const EdgeInsets.all(spacingXS), + child: TextFormField( + controller: _destinationController, + decoration: InputDecoration( + hintStyle: Theme.of(context).textTheme.labelSmall, + hintText: '+E164 phone number or SIP URI', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(spacingS), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: active_text_field_color), + borderRadius: BorderRadius.circular(spacingS), + ), + ), + ), + ), + const SizedBox(height: spacingXXXXL), + if (clientState == CallStateStatus.idle) + Center( + child: CallButton( + onPressed: () { + // ToDo add call functionality + }, + ), + ) + else if (clientState == CallStateStatus.ongoingInvitation) + Center( + child: CallInvitation( + onAccept: () { + // ToDo add accept functionality + }, + onDecline: () { + // ToDo add decline functionality + }, + ), + ) + else if (clientState == CallStateStatus.ongoingCall) + Center( + child: CallButton( + onPressed: () { + // ToDo add hangup functionality + }, + ), + ), + ], + ); + } +} + +class OnGoingCallControls extends StatelessWidget { + final VoidCallback onHangup; + + const OnGoingCallControls({super.key, required this.onHangup}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + CallControlButton( + enabledIcon: Icons.mic, + disabledIcon: Icons.mic_off, + isDisabled: false, + /* ToDo get this from provider */ + onEnabled: () { + // ToDo add mute functionality + }, + onDisabled: () { + // ToDo add unmute functionality + }, + ), + DeclineButton( + onPressed: () { + // ToDo add hangup functionality + }, + ), + CallControlButton( + enabledIcon: Icons.volume_up, + disabledIcon: Icons.volume_off, + isDisabled: false, + /* ToDo get this from provider */ + onEnabled: () { + // ToDo add speaker functionality + }, + onDisabled: () { + // ToDo add speaker functionality + }, + ), + ], + ), + Row( + children: [ + CallControlButton( + enabledIcon: Icons.play_arrow, + disabledIcon: Icons.pause, + isDisabled: false, + /* ToDo get this from provider */ + onEnabled: () { + // ToDo add hold functionality + }, + onDisabled: () { + // ToDo add unhold functionality + }, + ), + SizedBox(width: iconSize), + CallControlButton( + enabledIcon: Icons.dialpad, + disabledIcon: Icons.dialpad, + isDisabled: false, + onEnabled: () { + // ToDo add dialpad functionality + }, + onDisabled: () { + // ToDo add dialpad functionality + }, + ), + ], + ) + ], + ); + } +} + +class CallControlButton extends StatefulWidget { + final IconData enabledIcon; + final IconData disabledIcon; + final bool isDisabled; + final VoidCallback onEnabled; + final VoidCallback onDisabled; + + const CallControlButton({ + super.key, + required this.enabledIcon, + required this.disabledIcon, + required this.isDisabled, + required this.onEnabled, + required this.onDisabled, + }); + + @override + State createState() => _CallControlButtonState(); +} + +class _CallControlButtonState extends State { + @override + Widget build(BuildContext context) { + return Container( + width: iconSize, + height: iconSize, + decoration: BoxDecoration( + color: call_control_color, + shape: BoxShape.circle, + ), + child: IconButton( + icon: + Icon(widget.isDisabled ? widget.disabledIcon : widget.enabledIcon), + onPressed: widget.isDisabled ? widget.onDisabled : widget.onEnabled, + ), + ); + } +} + +class CallInvitation extends StatelessWidget { + final VoidCallback onAccept; + final VoidCallback onDecline; + + const CallInvitation( + {super.key, required this.onAccept, required this.onDecline}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + CallButton( + onPressed: onAccept, + ), + DeclineButton( + onPressed: onDecline, + ), + ], + ); + } +} + +abstract class BaseButton extends StatelessWidget { + final VoidCallback onPressed; + final String iconPath; + + const BaseButton( + {super.key, required this.onPressed, required this.iconPath}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + child: SvgPicture.asset( + iconPath, + width: iconSize, + height: iconSize, + ), + ); + } +} + +class CallButton extends BaseButton { + const CallButton({super.key, required VoidCallback onPressed}) + : super(onPressed: onPressed, iconPath: green_call_icon); +} + +class DeclineButton extends BaseButton { + const DeclineButton({super.key, required VoidCallback onPressed}) + : super(onPressed: onPressed, iconPath: red_decline_icon); +} diff --git a/pubspec.yaml b/pubspec.yaml index ca3bb2f..20ce740 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: path_provider: ^2.1.5 flutter_fgbg: ^0.6.0 provider: ^6.1.2 + flutter_svg: ^2.0.17 dev_dependencies: flutter_test: @@ -39,3 +40,4 @@ flutter: - assets/audio/ringback_tone.mp3 - assets/launcher.png - assets/images/ + - assets/icons/ From 042e35d9342a9ed6417b297fe2bb508fcb563760 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 28 Jan 2025 16:02:27 +0000 Subject: [PATCH 18/45] feat: implement remaining call control features + DTMF --- lib/utils/dimensions.dart | 2 +- lib/view/screen/call_screen.dart | 15 - lib/view/screen/home_screen.dart | 22 +- lib/view/screen/homes_screen.dart | 13 +- lib/view/telnyx_client_view_model.dart | 46 +- .../widgets/call_controls/call_controls.dart | 121 +++--- lib/view/widgets/dialpad_widget.dart | 392 ++++-------------- lib/view/widgets/header/control_header.dart | 7 +- lib/view/widgets/invitation_widget.dart | 45 -- .../profile_switcher_bottom_sheet.dart | 8 +- 10 files changed, 219 insertions(+), 452 deletions(-) delete mode 100644 lib/view/widgets/invitation_widget.dart diff --git a/lib/utils/dimensions.dart b/lib/utils/dimensions.dart index 77b2d60..b7ca45a 100644 --- a/lib/utils/dimensions.dart +++ b/lib/utils/dimensions.dart @@ -16,4 +16,4 @@ const fontSizeL = 18.0; const fontSizeXL = 24.0; const fontSizeXXL = 32.0; -const iconSize = 64.0; +const iconSize = 72.0; diff --git a/lib/view/screen/call_screen.dart b/lib/view/screen/call_screen.dart index 757b20a..7ae8c1e 100644 --- a/lib/view/screen/call_screen.dart +++ b/lib/view/screen/call_screen.dart @@ -31,21 +31,6 @@ class _CallScreenState extends State { const SizedBox(height: 16), Text(widget.call?.sessionDestinationNumber ?? 'Unknown Caller'), const SizedBox(height: 8), - DialPad( - backspaceButtonIconColor: Colors.red, - dialButtonColor: Colors.red, - makeCall: (number) { - //End call - Provider.of(context, listen: false) - .endCall(endfromCallScreen: true); - }, - keyPressed: (number) { - callInputController.text = - callInputController.value.text + number; - Provider.of(context, listen: false) - .dtmf(number); - }, - ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, diff --git a/lib/view/screen/home_screen.dart b/lib/view/screen/home_screen.dart index 5b6eb61..d99f317 100644 --- a/lib/view/screen/home_screen.dart +++ b/lib/view/screen/home_screen.dart @@ -5,7 +5,6 @@ import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:provider/provider.dart'; import 'package:logger/logger.dart'; import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; -import 'package:telnyx_flutter_webrtc/view/widgets/invitation_widget.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -41,10 +40,12 @@ class _HomeScreenState extends State { } void _observeResponses() { - invitation = Provider.of(context, listen: true).callState == - CallStateStatus.ongoingInvitation; - ongoingCall = Provider.of(context, listen: true).callState == - CallStateStatus.ongoingCall; + invitation = + Provider.of(context, listen: true).callState == + CallStateStatus.ongoingInvitation; + ongoingCall = + Provider.of(context, listen: true).callState == + CallStateStatus.ongoingCall; } void _callDestination() { @@ -77,15 +78,10 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { _observeResponses(); - if (invitation) { - return InvitationWidget( - title: 'Home', - invitation: Provider.of(context, listen: false) - .incomingInvitation, - ); - } else if (ongoingCall) { + if (ongoingCall) { return CallScreen( - call: Provider.of(context, listen: false).currentCall, + call: Provider.of(context, listen: false) + .currentCall, ); } else { return Scaffold( diff --git a/lib/view/screen/homes_screen.dart b/lib/view/screen/homes_screen.dart index eaf32d2..9b083b2 100644 --- a/lib/view/screen/homes_screen.dart +++ b/lib/view/screen/homes_screen.dart @@ -3,11 +3,9 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; -import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/call_controls.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/header/control_header.dart'; -import 'package:telnyx_flutter_webrtc/view/widgets/invitation_widget.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/login/login_controls.dart'; class HomesScreen extends StatefulWidget { @@ -47,16 +45,9 @@ class _HomesScreenState extends State { const ControlHeaders(), const SizedBox(height: spacingS), if (clientState == CallStateStatus.disconnected) - const LoginControls(), - if (clientState == CallStateStatus.idle) + const LoginControls() + else const CallControls(), - if (clientState == CallStateStatus.ringing) const Text('Ringing'), - if (clientState == CallStateStatus.ongoingInvitation) - const InvitationWidget( - title: '', - ), - if (clientState == CallStateStatus.ongoingCall) - const CallScreen(), ], ), ), diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index aff4eed..0cf7c08 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -36,9 +36,13 @@ class TelnyxClientViewModel with ChangeNotifier { bool _loggingIn = false; bool callFromPush = false; bool _speakerPhone = true; + bool _mute = false; + bool _hold = false; + CredentialConfig? _credentialConfig; IncomingInviteParams? _incomingInvite; + String _localName = ''; String _localNumber = ''; @@ -50,6 +54,22 @@ class TelnyxClientViewModel with ChangeNotifier { return _loggingIn; } + bool get speakerPhoneState { + return _speakerPhone; + } + + bool get muteState { + return _mute; + } + + bool get holdState { + return _hold; + } + + String get sessionId { + return _telnyxClient.sessid; + } + CallStateStatus _callState = CallStateStatus.disconnected; CallStateStatus get callState => _callState; @@ -75,6 +95,10 @@ class TelnyxClientViewModel with ChangeNotifier { void resetCallInfo() { logger.i('TxClientViewModel :: Reset Call Info'); _incomingInvite = null; + _currentCall = null; + _speakerPhone = false; + _mute = false; + _hold = false; callState = CallStateStatus.idle; updateCallFromPush(false); notifyListeners(); @@ -325,12 +349,6 @@ class TelnyxClientViewModel with ChangeNotifier { observeCurrentCall(); } - void toggleSpeakerPhone() { - _speakerPhone = !_speakerPhone; - currentCall?.enableSpeakerPhone(_speakerPhone); - notifyListeners(); - } - bool waitingForInvite = false; Future getCredentialConfig() async { @@ -468,15 +486,25 @@ class TelnyxClientViewModel with ChangeNotifier { } void dtmf(String tone) { - _telnyxClient.call.dtmf(_telnyxClient.call.callId, tone); + currentCall?.dtmf(_telnyxClient.call.callId, tone); } void muteUnmute() { - _telnyxClient.call.onMuteUnmutePressed(); + _mute = !_mute; + _currentCall?.onMuteUnmutePressed(); + notifyListeners(); } void holdUnhold() { - _telnyxClient.call.onHoldUnholdPressed(); + _hold = !_hold; + currentCall?.onHoldUnholdPressed(); + notifyListeners(); + } + + void toggleSpeakerPhone() { + _speakerPhone = !_speakerPhone; + currentCall?.enableSpeakerPhone(_speakerPhone); + notifyListeners(); } void exportLogs() async { diff --git a/lib/view/widgets/call_controls/call_controls.dart b/lib/view/widgets/call_controls/call_controls.dart index 10cd4de..fb8eaec 100644 --- a/lib/view/widgets/call_controls/call_controls.dart +++ b/lib/view/widgets/call_controls/call_controls.dart @@ -5,6 +5,7 @@ import 'package:telnyx_flutter_webrtc/utils/asset_paths.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/utils/theme.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/dialpad_widget.dart'; class CallControls extends StatefulWidget { const CallControls({super.key}); @@ -36,6 +37,8 @@ class _CallControlsState extends State { Padding( padding: const EdgeInsets.all(spacingXS), child: TextFormField( + readOnly: clientState != CallStateStatus.idle, + enabled: clientState == CallStateStatus.idle, controller: _destinationController, decoration: InputDecoration( hintStyle: Theme.of(context).textTheme.labelSmall, @@ -55,7 +58,18 @@ class _CallControlsState extends State { Center( child: CallButton( onPressed: () { - // ToDo add call functionality + final destination = _destinationController.text; + if (destination.isNotEmpty) { + context.read().call(destination); + } + }, + ), + ) + else if (clientState == CallStateStatus.ringing) + Center( + child: DeclineButton( + onPressed: () { + context.read().endCall(); }, ), ) @@ -63,20 +77,16 @@ class _CallControlsState extends State { Center( child: CallInvitation( onAccept: () { - // ToDo add accept functionality + context.read().accept(); }, onDecline: () { - // ToDo add decline functionality + context.read().endCall(); }, ), ) else if (clientState == CallStateStatus.ongoingCall) Center( - child: CallButton( - onPressed: () { - // ToDo add hangup functionality - }, - ), + child: OnGoingCallControls(), ), ], ); @@ -84,59 +94,56 @@ class _CallControlsState extends State { } class OnGoingCallControls extends StatelessWidget { - final VoidCallback onHangup; - - const OnGoingCallControls({super.key, required this.onHangup}); + const OnGoingCallControls({ + super.key, + }); @override Widget build(BuildContext context) { return Column( children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ CallControlButton( enabledIcon: Icons.mic, disabledIcon: Icons.mic_off, - isDisabled: false, - /* ToDo get this from provider */ - onEnabled: () { - // ToDo add mute functionality - }, - onDisabled: () { - // ToDo add unmute functionality + isDisabled: context.select( + (txClient) => txClient.muteState, + ), + onToggle: () { + context.read().muteUnmute(); }, ), DeclineButton( onPressed: () { - // ToDo add hangup functionality + context.read().endCall(); }, ), CallControlButton( enabledIcon: Icons.volume_up, disabledIcon: Icons.volume_off, - isDisabled: false, - /* ToDo get this from provider */ - onEnabled: () { - // ToDo add speaker functionality - }, - onDisabled: () { - // ToDo add speaker functionality + isDisabled: context.select( + (txClient) => txClient.speakerPhoneState, + ), + onToggle: () { + context.read().toggleSpeakerPhone(); }, ), ], ), + SizedBox(height: spacingM), Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ CallControlButton( enabledIcon: Icons.play_arrow, disabledIcon: Icons.pause, - isDisabled: false, - /* ToDo get this from provider */ - onEnabled: () { - // ToDo add hold functionality - }, - onDisabled: () { - // ToDo add unhold functionality + isDisabled: context.select( + (txClient) => txClient.holdState, + ), + onToggle: () { + context.read().holdUnhold(); }, ), SizedBox(width: iconSize), @@ -144,15 +151,28 @@ class OnGoingCallControls extends StatelessWidget { enabledIcon: Icons.dialpad, disabledIcon: Icons.dialpad, isDisabled: false, - onEnabled: () { - // ToDo add dialpad functionality - }, - onDisabled: () { - // ToDo add dialpad functionality + onToggle: () { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: DialPad( + onDigitPressed: (digit) { + context.read().dtmf(digit); + }, + ), + ); + }, + ); }, ), ], - ) + ), ], ); } @@ -162,16 +182,14 @@ class CallControlButton extends StatefulWidget { final IconData enabledIcon; final IconData disabledIcon; final bool isDisabled; - final VoidCallback onEnabled; - final VoidCallback onDisabled; + final VoidCallback onToggle; const CallControlButton({ super.key, required this.enabledIcon, required this.disabledIcon, required this.isDisabled, - required this.onEnabled, - required this.onDisabled, + required this.onToggle, }); @override @@ -191,7 +209,7 @@ class _CallControlButtonState extends State { child: IconButton( icon: Icon(widget.isDisabled ? widget.disabledIcon : widget.enabledIcon), - onPressed: widget.isDisabled ? widget.onDisabled : widget.onEnabled, + onPressed: widget.onToggle, ), ); } @@ -201,8 +219,11 @@ class CallInvitation extends StatelessWidget { final VoidCallback onAccept; final VoidCallback onDecline; - const CallInvitation( - {super.key, required this.onAccept, required this.onDecline}); + const CallInvitation({ + super.key, + required this.onAccept, + required this.onDecline, + }); @override Widget build(BuildContext context) { @@ -212,6 +233,7 @@ class CallInvitation extends StatelessWidget { CallButton( onPressed: onAccept, ), + SizedBox(width: spacingM), DeclineButton( onPressed: onDecline, ), @@ -224,8 +246,11 @@ abstract class BaseButton extends StatelessWidget { final VoidCallback onPressed; final String iconPath; - const BaseButton( - {super.key, required this.onPressed, required this.iconPath}); + const BaseButton({ + super.key, + required this.onPressed, + required this.iconPath, + }); @override Widget build(BuildContext context) { diff --git a/lib/view/widgets/dialpad_widget.dart b/lib/view/widgets/dialpad_widget.dart index 22f2217..210953c 100644 --- a/lib/view/widgets/dialpad_widget.dart +++ b/lib/view/widgets/dialpad_widget.dart @@ -1,331 +1,113 @@ -import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_masked_text2/flutter_masked_text2.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; +import 'package:telnyx_flutter_webrtc/utils/theme.dart'; class DialPad extends StatefulWidget { - final ValueSetter? makeCall; - final ValueSetter? keyPressed; - final bool? hideDialButton; - - // buttonColor is the color of the button on the dial pad. defaults to Colors.gray - final Color? buttonColor; - final Color? buttonTextColor; - final Color? dialButtonColor; - final Color? dialButtonIconColor; - final IconData? dialButtonIcon; - final Color? backspaceButtonIconColor; - final Color? dialOutputTextColor; - - // outputMask is the mask applied to the output text. Defaults to (000) 000-0000 - final String? outputMask; + final ValueChanged? onDigitPressed; const DialPad({ - super.key, - this.makeCall, - this.keyPressed, - this.hideDialButton, - this.outputMask, - this.buttonColor, - this.buttonTextColor, - this.dialButtonColor, - this.dialButtonIconColor, - this.dialButtonIcon, - this.dialOutputTextColor, - this.backspaceButtonIconColor, - }); + Key? key, + this.onDigitPressed, + }) : super(key: key); @override - DialPadState createState() => DialPadState(); + _DialPadState createState() => _DialPadState(); } -class DialPadState extends State { - MaskedTextController? textEditingController; - var _value = ''; - var mainTitle = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']; - var subTitle = [ - '', - 'ABC', - 'DEF', - 'GHI', - 'JKL', - 'MNO', - 'PQRS', - 'TUV', - 'WXYZ', - null, - '+', - null, +class _DialPadState extends State { + final TextEditingController _controller = TextEditingController(); + + /// All standard dialpad digits + final List _digits = [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '*', + '0', + '#', ]; - @override - void initState() { - textEditingController = - MaskedTextController(mask: widget.outputMask ?? '(000) 000-0000'); - super.initState(); - } - - void _setText(String? value) async { - if (widget.keyPressed != null) widget.keyPressed!(value!); - + void _handleDigitPress(String digit) { + widget.onDigitPressed?.call(digit); setState(() { - _value += value!; - textEditingController!.text = _value; + _controller.text += digit; }); } - List _getDialerButtons() { - final rows = []; - var items = []; - - for (var i = 0; i < mainTitle.length; i++) { - if (i % 3 == 0 && i > 0) { - rows - ..add( - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: items, - ), - ) - ..add( - const SizedBox( - height: 12, - ), - ); - items = []; - } - - items.add( - DialButton( - title: mainTitle[i], - subtitle: subTitle[i], - color: widget.buttonColor, - textColor: widget.buttonTextColor, - onTap: _setText, - ), - ); - } - rows - ..add( - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: items), - ) - ..add( - const SizedBox( - height: 12, - ), - ); - - return rows; - } - @override Widget build(BuildContext context) { - final screenSize = MediaQuery.of(context).size; - final sizeFactor = screenSize.height * 0.09852217; - - return Center( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(20), - child: TextFormField( + return Container( + height: MediaQuery.of(context).size.height * 0.80, + color: surfaceColor, + padding: const EdgeInsets.symmetric( + vertical: spacingL, + horizontal: spacingXXL, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + 'DTMF Dialpad', + style: Theme.of(context).textTheme.headlineMedium, + ), + Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + TextField( + controller: _controller, readOnly: true, - style: TextStyle( - color: widget.dialOutputTextColor ?? Colors.black, - fontSize: sizeFactor / 2, - ), textAlign: TextAlign.center, - decoration: const InputDecoration(border: InputBorder.none), - controller: textEditingController, - ), - ), - ..._getDialerButtons(), - const SizedBox( - height: 15, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: Container(), + style: const TextStyle(fontSize: fontSizeXL, color: Colors.black), + decoration: const InputDecoration( + border: InputBorder.none, ), - Expanded( - child: widget.hideDialButton != null && widget.hideDialButton! - ? Container() - : Center( - child: DialButton( - icon: widget.dialButtonIcon ?? Icons.phone, - color: widget.dialButtonColor != null - ? widget.dialButtonColor! - : Colors.green, - onTap: (value) { - widget.makeCall!(_value); - }, - ), - ), + ), + + const SizedBox(height: spacingM), + + // 3x4 grid of digit buttons + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _digits.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: spacingS, + crossAxisSpacing: spacingS, ), - Expanded( - child: Padding( - padding: - EdgeInsets.only(right: screenSize.height * 0.03685504), - child: IconButton( - icon: Icon( - Icons.backspace, - size: sizeFactor / 2, - color: _value.isNotEmpty - ? (widget.backspaceButtonIconColor ?? Colors.white24) - : Colors.white24, - ), - onPressed: _value.isEmpty - ? null - : () { - if (_value.isNotEmpty) { - setState(() { - _value = _value.substring(0, _value.length - 1); - textEditingController!.text = _value; - }); - } - }, + itemBuilder: (context, index) { + final digit = _digits[index]; + return ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundColor: telnyx_soft_black, + backgroundColor: call_control_color, + padding: const EdgeInsets.all(spacingL), ), - ), - ), - ], - ), - ], - ), - ); - } -} - -class DialButton extends StatefulWidget { - final String? title; - final String? subtitle; - final Color? color; - final Color? textColor; - final IconData? icon; - final Color? iconColor; - final ValueSetter? onTap; - final bool? shouldAnimate; - - const DialButton({ - super.key, - this.title, - this.subtitle, - this.color, - this.textColor, - this.icon, - this.iconColor, - this.shouldAnimate, - this.onTap, - }); - - @override - DialButtonState createState() => DialButtonState(); -} - -class DialButtonState extends State - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _colorTween; - Timer? _timer; - - @override - void initState() { - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - ); - _colorTween = - ColorTween(begin: widget.color ?? Colors.white24, end: Colors.white) - .animate(_animationController); - - super.initState(); - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - if ((widget.shouldAnimate == null || widget.shouldAnimate!) && - _timer != null) _timer!.cancel(); - } - - @override - Widget build(BuildContext context) { - final screenSize = MediaQuery.of(context).size; - final sizeFactor = screenSize.height * 0.09852217; - - return GestureDetector( - onTap: () { - if (widget.onTap != null) widget.onTap!(widget.title); - - if (widget.shouldAnimate == null || widget.shouldAnimate!) { - if (_animationController.status == AnimationStatus.completed) { - _animationController.reverse(); - } else { - _animationController.forward(); - _timer = Timer(const Duration(milliseconds: 200), () { - setState(() { - _animationController.reverse(); - }); - }); - } - } - }, - child: ClipOval( - child: AnimatedBuilder( - animation: _colorTween, - builder: (context, child) => Container( - color: _colorTween.value, - height: sizeFactor, - width: sizeFactor, - child: Center( - child: widget.icon == null - ? widget.subtitle != null - ? SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: 8, - ), - Text( - widget.title!, - style: TextStyle( - fontSize: sizeFactor / 2, - color: widget.textColor ?? Colors.black, - ), - ), - Text( - widget.subtitle!, - style: TextStyle( - color: widget.textColor ?? Colors.black, - ), - ), - ], - ), - ) - : Padding( - padding: EdgeInsets.only( - top: widget.title == '*' ? 10 : 0, - ), - child: Text( - widget.title!, - style: TextStyle( - fontSize: - widget.title == '*' && widget.subtitle == null - ? screenSize.height * 0.0862069 - : sizeFactor / 2, - color: widget.textColor ?? Colors.black, - ), - ), - ) - : Icon( - widget.icon, - size: sizeFactor / 2, - color: widget.iconColor ?? Colors.white, - ), + onPressed: () => _handleDigitPress(digit), + child: Text( + digit, + style: const TextStyle(fontSize: fontSizeL), + ), + ); + }, ), - ), + ], ), ), ); diff --git a/lib/view/widgets/header/control_header.dart b/lib/view/widgets/header/control_header.dart index 2a6791b..b825a65 100644 --- a/lib/view/widgets/header/control_header.dart +++ b/lib/view/widgets/header/control_header.dart @@ -42,7 +42,12 @@ class _ControlHeadersState extends State { const SizedBox(height: spacingXL), Text('Session ID', style: Theme.of(context).textTheme.labelMedium), const SizedBox(height: spacingS), - const Text('-'), + Text( + context.select( + (txClient) => txClient.sessionId, + ), + ), + const SizedBox(height: spacingXL), ], ); }, diff --git a/lib/view/widgets/invitation_widget.dart b/lib/view/widgets/invitation_widget.dart deleted file mode 100644 index d22cbbc..0000000 --- a/lib/view/widgets/invitation_widget.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; -import 'package:provider/provider.dart'; -import 'package:telnyx_webrtc/model/verto/receive/received_message_body.dart'; - -class InvitationWidget extends StatelessWidget { - const InvitationWidget({super.key, required this.title, this.invitation}); - final String title; - final IncomingInviteParams? invitation; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(title), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(invitation?.callerIdName ?? 'Unknown Caller'), - Text(invitation?.callerIdNumber ?? 'Unknown Number'), - const Text('Incoming Call'), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - style: TextButton.styleFrom( - foregroundColor: Colors.red[400], - ), - onPressed: () { - Provider.of(context, listen: false) - .endCall(); - print('Decline Call'); - }, - child: const Text('Decline'), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart index 65df6bd..8460c55 100644 --- a/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart +++ b/lib/view/widgets/login/bottom_sheet/profile_switcher_bottom_sheet.dart @@ -23,20 +23,20 @@ class _ProfileSwitcherBottomSheetState Widget build(BuildContext context) { return Container( height: MediaQuery.of(context).size.height * 0.80, - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(spacingL), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: spacingS), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'Existing Profiles', style: TextStyle( - fontSize: 20, + fontSize: fontSizeL, fontWeight: FontWeight.bold, ), ), @@ -65,7 +65,7 @@ class _ProfileSwitcherBottomSheetState style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.surface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24.0), + borderRadius: BorderRadius.circular(spacingXXL), ), ), ), From 046479aa7c9c4aa58e6df362752cc3efba5c7133 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 28 Jan 2025 16:33:46 +0000 Subject: [PATCH 19/45] chore: general project cleanup and background_detector implementation --- .idea/libraries/Flutter_Plugins.xml | 4 +- lib/main.dart | 55 +++--- lib/service/notification_service.dart | 17 +- lib/utils/background_detector.dart | 82 ++++++++ lib/utils/dimensions.dart | 4 + lib/view/screen/call_screen.dart | 70 ------- lib/view/screen/home_screen.dart | 104 +++------- lib/view/screen/homes_screen.dart | 57 ------ lib/view/telnyx_client_view_model.dart | 47 ++--- .../call_controls/buttons/call_buttons.dart | 75 +++++++ .../widgets/call_controls/call_controls.dart | 185 +----------------- .../call_controls/call_invitation.dart | 30 +++ .../call_controls/ongoing_call_controls.dart | 91 +++++++++ lib/view/widgets/header/control_header.dart | 6 +- pubspec.yaml | 3 +- 15 files changed, 383 insertions(+), 447 deletions(-) create mode 100644 lib/utils/background_detector.dart delete mode 100644 lib/view/screen/call_screen.dart delete mode 100644 lib/view/screen/homes_screen.dart create mode 100644 lib/view/widgets/call_controls/buttons/call_buttons.dart create mode 100644 lib/view/widgets/call_controls/call_invitation.dart create mode 100644 lib/view/widgets/call_controls/ongoing_call_controls.dart diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml index eca295a..ef281f0 100644 --- a/.idea/libraries/Flutter_Plugins.xml +++ b/.idea/libraries/Flutter_Plugins.xml @@ -11,12 +11,10 @@ - - @@ -26,6 +24,8 @@ + + diff --git a/lib/main.dart b/lib/main.dart index 67e28b9..e74e1d3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,13 +7,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; -import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:telnyx_flutter_webrtc/provider/profile_provider.dart'; -import 'package:telnyx_flutter_webrtc/view/screen/homes_screen.dart'; +import 'package:telnyx_flutter_webrtc/utils/background_detector.dart'; +import 'package:telnyx_flutter_webrtc/view/screen/home_screen.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/service/notification_service.dart'; -import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; -import 'package:telnyx_flutter_webrtc/view/screen/home_screen.dart'; import 'package:logger/logger.dart'; import 'package:provider/provider.dart'; import 'package:telnyx_webrtc/model/push_notification.dart'; @@ -44,7 +42,6 @@ class AppInitializer { Future initialize() async { if (!_isInitialized) { _isInitialized = true; - var incomingPushCall = false; if (!kIsWeb) { // generate random number as string logger.i('FlutterCallkitIncoming :: Initializing listening for events'); @@ -52,7 +49,6 @@ class AppInitializer { logger.i('onEvent :: ${event?.event} :: ${event?.body}'); switch (event!.event) { case Event.actionCallIncoming: - incomingPushCall = true; // retrieve the push metadata from extras if (event.body['extra']['metadata'] == null) { logger.i('actionCallIncoming :: Push Data is null!'); @@ -71,21 +67,24 @@ class AppInitializer { logger.i('actionCallStart :: call start'); break; case Event.actionCallAccept: - final metadata = event.body['extra']['metadata']; - if (metadata == null || (incomingPushCall && fromBackground)) { - logger.i('Accepted Call Directly'); + if (txClientViewModel.incomingInvitation != null) { await txClientViewModel.accept(); - - /// Reset the incomingPushCall flag and fromBackground flag - incomingPushCall = false; - fromBackground = false; } else { - logger.i( - 'Received push Call with metadata on Accept, handle push here $metadata', - ); - final data = metadata as Map; - data['isAnswer'] = true; - await handlePush(data); + final metadata = event.body['extra']['metadata']; + if (metadata == null || fromBackground) { + logger.i('Accepted Call Directly'); + await txClientViewModel.accept(); + + /// Reset the incomingPushCall flag and fromBackground flag + fromBackground = false; + } else { + logger.i( + 'Received push Call with metadata on Accept, handle push here $metadata', + ); + final data = metadata as Map; + data['isAnswer'] = true; + await handlePush(data); + } } break; case Event.actionCallDecline: @@ -282,23 +281,25 @@ Future main() async { final credentialConfig = await txClientViewModel.getCredentialConfig(); runApp( - FGBGNotifier( - onEvent: (FGBGType type) => switch (type) { - FGBGType.foreground => { + BackgroundDetector( + onLifecycleEvent: (AppLifecycleState state) => { + if (state == AppLifecycleState.resumed) + { logger.i('We are in the foreground, CONNECTING'), // Check if we are from push, if we are do nothing, reconnection will happen there in handlePush. Otherwise connect if (!txClientViewModel.callFromPush) { txClientViewModel.login(credentialConfig), }, - }, - FGBGType.background => { + } + else if (state == AppLifecycleState.paused) + { logger.i( 'We are in the background setting fromBackground == true, DISCONNECTING', ), fromBackground = true, txClientViewModel.disconnect(), - } + }, }, child: const MyApp(), ), @@ -400,9 +401,7 @@ class _MyAppState extends State { theme: AppTheme.lightTheme, initialRoute: '/', routes: { - '/': (context) => const HomesScreen(), - '/home': (context) => const HomeScreen(), - '/call': (context) => const CallScreen(), + '/': (context) => const HomeScreen(), }, ), ); diff --git a/lib/service/notification_service.dart b/lib/service/notification_service.dart index 5d2e28f..05dc5c6 100644 --- a/lib/service/notification_service.dart +++ b/lib/service/notification_service.dart @@ -5,17 +5,27 @@ import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/entities/ios_params.dart'; import 'package:flutter_callkit_incoming/entities/notification_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:telnyx_flutter_webrtc/utils/background_detector.dart'; import 'package:uuid/uuid.dart'; import 'package:logger/logger.dart'; import 'package:telnyx_webrtc/model/push_notification.dart'; class NotificationService { static Future showNotification(RemoteMessage message) async { - Logger().i('Received Incoming NotificationService! from background'); - final metadata = - PushMetaData.fromJson(jsonDecode(message.data['metadata'])); + Logger().i('Received Incoming NotificationService! from background ${message.data}'); + + final data = message.data.containsKey('extra') ? jsonDecode(message.data['extra']) : {}; + final alert = data['aps']?['alert'] ?? 'No alert'; + + if (alert == 'Missed call!') { + Logger().i('Missed call notification, do not show call kit'); + return; + } + + final metadata = PushMetaData.fromJson(jsonDecode(message.data['metadata'])); final currentUuid = const Uuid().v4(); + BackgroundDetector.ignore = true; final CallKitParams callKitParams = CallKitParams( id: currentUuid, nameCaller: metadata.callerName, @@ -68,7 +78,6 @@ class NotificationService { Logger().i('Received Incoming NotificationService! from background'); final metadata = PushMetaData.fromJson(jsonDecode(message.data['metadata'])); - final received = message.data['message']; final currentUuid = const Uuid().v4(); final CallKitParams callKitParams = CallKitParams( diff --git a/lib/utils/background_detector.dart b/lib/utils/background_detector.dart new file mode 100644 index 0000000..ed3523e --- /dev/null +++ b/lib/utils/background_detector.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +class BackgroundDetector extends StatefulWidget { + static BackgroundDetector? _instance; + + factory BackgroundDetector({ + Key? key, + required Widget child, + void Function(AppLifecycleState)? onLifecycleEvent, + }) { + _instance ??= BackgroundDetector._internal( + key: key, + onLifecycleEvent: onLifecycleEvent, + child: child, + ); + return _instance!; + } + + const BackgroundDetector._internal({ + Key? key, + required this.child, + this.onLifecycleEvent, + }) : super(key: key); + + final Widget child; + + /// Callback to invoke on lifecycle events (if not ignored). + final void Function(AppLifecycleState)? onLifecycleEvent; + + // -------------------------------------------------------------------------- + // IGNORE / IGNOREWHILE LOGIC (STATIC) + // -------------------------------------------------------------------------- + static bool _ignoreLifecycleEvents = false; + + /// Whether to globally ignore lifecycle events + static bool get ignore => _ignoreLifecycleEvents; + static set ignore(bool value) => _ignoreLifecycleEvents = value; + + /// Temporarily ignore lifecycle events during [action]. + /// Reverts to the old ignore state afterward. + static Future ignoreWhile(FutureOr Function() action) async { + final wasIgnoring = _ignoreLifecycleEvents; + _ignoreLifecycleEvents = true; + try { + return await action(); + } finally { + _ignoreLifecycleEvents = wasIgnoring; + } + } + + @override + State createState() => _BackgroundDetectorState(); +} + +class _BackgroundDetectorState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Only emit if NOT ignoring + if (!BackgroundDetector.ignore) { + widget.onLifecycleEvent?.call(state); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/lib/utils/dimensions.dart b/lib/utils/dimensions.dart index b7ca45a..0f49bb3 100644 --- a/lib/utils/dimensions.dart +++ b/lib/utils/dimensions.dart @@ -8,6 +8,7 @@ const double spacingXXL = 24.0; const double spacingXXXL = 28.0; const double spacingXXXXL = 32.0; const double spacingXXXXXL = 36.0; +const double spacingXXXXXXL = 42.0; const fontSizeXS = 8.0; const fontSizeS = 12.0; @@ -17,3 +18,6 @@ const fontSizeXL = 24.0; const fontSizeXXL = 32.0; const iconSize = 72.0; + +const logoHeight = 58.0; +const logoWidth = 222.0; \ No newline at end of file diff --git a/lib/view/screen/call_screen.dart b/lib/view/screen/call_screen.dart deleted file mode 100644 index 7ae8c1e..0000000 --- a/lib/view/screen/call_screen.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:logger/logger.dart'; -import 'package:provider/provider.dart'; -import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; -import 'package:telnyx_flutter_webrtc/view/widgets/dialpad_widget.dart'; -import 'package:telnyx_webrtc/call.dart'; - -class CallScreen extends StatefulWidget { - const CallScreen({super.key, this.call}); - - final Call? call; - - @override - State createState() => _CallScreenState(); -} - -class _CallScreenState extends State { - final logger = Logger(); - TextEditingController callInputController = TextEditingController(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text('Ongoing Call'), - ), - body: SingleChildScrollView( - child: Center( - child: Column( - children: [ - const SizedBox(height: 16), - Text(widget.call?.sessionDestinationNumber ?? 'Unknown Caller'), - const SizedBox(height: 8), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - onPressed: () { - print('mic'); - Provider.of(context, listen: false) - .muteUnmute(); - }, - icon: const Icon(Icons.mic), - ), - IconButton( - onPressed: () { - print('speakerphone'); - Provider.of(context, listen: false) - .toggleSpeakerPhone(); - }, - icon: const Icon(Icons.volume_up), - ), - IconButton( - onPressed: () { - print('pause'); - Provider.of(context, listen: false) - .holdUnhold(); - }, - icon: const Icon(Icons.pause), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/view/screen/home_screen.dart b/lib/view/screen/home_screen.dart index d99f317..0d88aad 100644 --- a/lib/view/screen/home_screen.dart +++ b/lib/view/screen/home_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:provider/provider.dart'; -import 'package:logger/logger.dart'; -import 'package:telnyx_flutter_webrtc/view/screen/call_screen.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/call_controls.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/header/control_header.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/login/login_controls.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -14,12 +16,6 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - final logger = Logger(); - TextEditingController destinationController = TextEditingController(); - - bool invitation = false; - bool ongoingCall = false; - @override void initState() { super.initState(); @@ -30,64 +26,32 @@ class _HomeScreenState extends State { await FlutterCallkitIncoming.requestNotificationPermission('notification'); final status = await Permission.notification.status; if (status.isDenied) { - // We haven't asked for permission yet or the permission has been denied before, but not permanently await Permission.notification.request(); } - // You can also directly ask permission about its status. - if (await Permission.location.isRestricted) { - // The OS restricts access, for example, because of parental controls. - } - } - - void _observeResponses() { - invitation = - Provider.of(context, listen: true).callState == - CallStateStatus.ongoingInvitation; - ongoingCall = - Provider.of(context, listen: true).callState == - CallStateStatus.ongoingCall; - } - - void _callDestination() { - Provider.of(context, listen: false) - .call(destinationController.text); - logger.i('Calling!'); - } - - void _endCall() { - Provider.of(context, listen: false).endCall(); - logger.i('Calling!'); } void handleOptionClick(String value) { switch (value) { case 'Logout': Provider.of(context, listen: false).disconnect(); - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(context).pushReplacementNamed('/'); - }); - logger.i('Disconnecting!'); break; case 'Export Logs': Provider.of(context, listen: false).exportLogs(); - logger.i('Exporting logs!'); break; } } @override Widget build(BuildContext context) { - _observeResponses(); - if (ongoingCall) { - return CallScreen( - call: Provider.of(context, listen: false) - .currentCall, - ); - } else { - return Scaffold( - appBar: AppBar( - title: Text('Home'), - actions: [ + final clientState = context.select( + (txClient) => txClient.callState, + ); + + return Scaffold( + appBar: AppBar( + actions: [ + // Only allow to log out or export logs when client is idle (not on a call or disconnected) + if (clientState == CallStateStatus.idle) PopupMenuButton( onSelected: handleOptionClick, itemBuilder: (BuildContext context) { @@ -99,35 +63,23 @@ class _HomeScreenState extends State { }).toList(); }, ), - ], - ), - body: Center( + ], + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(spacingXXL), child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextFormField( - controller: destinationController, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Enter Destination Number', - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () { - _callDestination(); - }, - child: const Text('Call'), - ), - ), + children: [ + const ControlHeaders(), + const SizedBox(height: spacingS), + if (clientState == CallStateStatus.disconnected) + const LoginControls() + else + const CallControls(), ], ), ), - ); - } + ), + ); } } diff --git a/lib/view/screen/homes_screen.dart b/lib/view/screen/homes_screen.dart deleted file mode 100644 index 9b083b2..0000000 --- a/lib/view/screen/homes_screen.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:provider/provider.dart'; -import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; -import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; -import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/call_controls.dart'; -import 'package:telnyx_flutter_webrtc/view/widgets/header/control_header.dart'; -import 'package:telnyx_flutter_webrtc/view/widgets/login/login_controls.dart'; - -class HomesScreen extends StatefulWidget { - const HomesScreen({super.key}); - - @override - State createState() => _HomesScreenState(); -} - -class _HomesScreenState extends State { - @override - void initState() { - super.initState(); - askForNotificationPermission(); - } - - Future askForNotificationPermission() async { - await FlutterCallkitIncoming.requestNotificationPermission('notification'); - final status = await Permission.notification.status; - if (status.isDenied) { - await Permission.notification.request(); - } - } - - @override - Widget build(BuildContext context) { - final clientState = context.select( - (txClient) => txClient.callState, - ); - - return Scaffold( - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(spacingXXL), - child: Column( - children: [ - const ControlHeaders(), - const SizedBox(height: spacingS), - if (clientState == CallStateStatus.disconnected) - const LoginControls() - else - const CallControls(), - ], - ), - ), - ), - ); - } -} diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index 0cf7c08..d2e632b 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -10,6 +10,7 @@ import 'package:logger/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:telnyx_flutter_webrtc/file_logger.dart'; import 'package:telnyx_flutter_webrtc/main.dart'; +import 'package:telnyx_flutter_webrtc/utils/background_detector.dart'; import 'package:telnyx_webrtc/call.dart'; import 'package:telnyx_webrtc/config/telnyx_config.dart'; import 'package:telnyx_webrtc/model/socket_method.dart'; @@ -42,7 +43,6 @@ class TelnyxClientViewModel with ChangeNotifier { CredentialConfig? _credentialConfig; IncomingInviteParams? _incomingInvite; - String _localName = ''; String _localNumber = ''; @@ -94,6 +94,7 @@ class TelnyxClientViewModel with ChangeNotifier { void resetCallInfo() { logger.i('TxClientViewModel :: Reset Call Info'); + BackgroundDetector.ignore = false; _incomingInvite = null; _currentCall = null; _speakerPhone = false; @@ -316,6 +317,7 @@ class TelnyxClientViewModel with ChangeNotifier { void disconnect() { _telnyxClient.disconnect(); + callState = CallStateStatus.disconnected; _loggingIn = false; _registered = false; notifyListeners(); @@ -433,28 +435,27 @@ class TelnyxClientViewModel with ChangeNotifier { } Future showNotification(IncomingInviteParams message) async { - // Temporarily ignore FGBG events while showing the CallKit notification - FGBGEvents.ignoreWhile(() async { - final CallKitParams callKitParams = CallKitParams( - id: message.callID, - nameCaller: message.callerIdName, - appName: 'Telnyx Flutter Voice', - handle: message.callerIdNumber, - type: 0, - textAccept: 'Accept', - textDecline: 'Decline', - missedCallNotification: const NotificationParams( - showNotification: false, - isShowCallback: false, - subtitle: 'Missed call', - ), - duration: 30000, - extra: {}, - headers: {'platform': 'flutter'}, - ); + // Temporarily ignore lifecycle events during notification to avoid actions being done while app is in background and notification in foreground. + BackgroundDetector.ignore = true; + final CallKitParams callKitParams = CallKitParams( + id: message.callID, + nameCaller: message.callerIdName, + appName: 'Telnyx Flutter Voice', + handle: message.callerIdNumber, + type: 0, + textAccept: 'Accept', + textDecline: 'Decline', + missedCallNotification: const NotificationParams( + showNotification: false, + isShowCallback: false, + subtitle: 'Missed call', + ), + duration: 30000, + extra: {}, + headers: {'platform': 'flutter'}, + ); - await FlutterCallkitIncoming.showCallkitIncoming(callKitParams); - }); + await FlutterCallkitIncoming.showCallkitIncoming(callKitParams); } void endCall({bool endfromCallScreen = false}) { @@ -490,7 +491,7 @@ class TelnyxClientViewModel with ChangeNotifier { } void muteUnmute() { - _mute = !_mute; + _mute = !_mute; _currentCall?.onMuteUnmutePressed(); notifyListeners(); } diff --git a/lib/view/widgets/call_controls/buttons/call_buttons.dart b/lib/view/widgets/call_controls/buttons/call_buttons.dart new file mode 100644 index 0000000..71cd731 --- /dev/null +++ b/lib/view/widgets/call_controls/buttons/call_buttons.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:telnyx_flutter_webrtc/utils/asset_paths.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; +import 'package:telnyx_flutter_webrtc/utils/theme.dart'; + +abstract class BaseButton extends StatelessWidget { + final VoidCallback onPressed; + final String iconPath; + + const BaseButton({ + super.key, + required this.onPressed, + required this.iconPath, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + child: SvgPicture.asset( + iconPath, + width: iconSize, + height: iconSize, + ), + ); + } +} + +class CallButton extends BaseButton { + const CallButton({super.key, required VoidCallback onPressed}) + : super(onPressed: onPressed, iconPath: green_call_icon); +} + +class DeclineButton extends BaseButton { + const DeclineButton({super.key, required VoidCallback onPressed}) + : super(onPressed: onPressed, iconPath: red_decline_icon); +} + +class CallControlButton extends StatefulWidget { + final IconData enabledIcon; + final IconData disabledIcon; + final bool isDisabled; + final VoidCallback onToggle; + + const CallControlButton({ + super.key, + required this.enabledIcon, + required this.disabledIcon, + required this.isDisabled, + required this.onToggle, + }); + + @override + State createState() => _CallControlButtonState(); +} + +class _CallControlButtonState extends State { + @override + Widget build(BuildContext context) { + return Container( + width: iconSize, + height: iconSize, + decoration: BoxDecoration( + color: call_control_color, + shape: BoxShape.circle, + ), + child: IconButton( + icon: + Icon(widget.isDisabled ? widget.disabledIcon : widget.enabledIcon), + onPressed: widget.onToggle, + ), + ); + } +} diff --git a/lib/view/widgets/call_controls/call_controls.dart b/lib/view/widgets/call_controls/call_controls.dart index fb8eaec..b560858 100644 --- a/lib/view/widgets/call_controls/call_controls.dart +++ b/lib/view/widgets/call_controls/call_controls.dart @@ -5,6 +5,9 @@ import 'package:telnyx_flutter_webrtc/utils/asset_paths.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/utils/theme.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/buttons/call_buttons.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/call_invitation.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/ongoing_call_controls.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/dialpad_widget.dart'; class CallControls extends StatefulWidget { @@ -92,185 +95,3 @@ class _CallControlsState extends State { ); } } - -class OnGoingCallControls extends StatelessWidget { - const OnGoingCallControls({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - CallControlButton( - enabledIcon: Icons.mic, - disabledIcon: Icons.mic_off, - isDisabled: context.select( - (txClient) => txClient.muteState, - ), - onToggle: () { - context.read().muteUnmute(); - }, - ), - DeclineButton( - onPressed: () { - context.read().endCall(); - }, - ), - CallControlButton( - enabledIcon: Icons.volume_up, - disabledIcon: Icons.volume_off, - isDisabled: context.select( - (txClient) => txClient.speakerPhoneState, - ), - onToggle: () { - context.read().toggleSpeakerPhone(); - }, - ), - ], - ), - SizedBox(height: spacingM), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - CallControlButton( - enabledIcon: Icons.play_arrow, - disabledIcon: Icons.pause, - isDisabled: context.select( - (txClient) => txClient.holdState, - ), - onToggle: () { - context.read().holdUnhold(); - }, - ), - SizedBox(width: iconSize), - CallControlButton( - enabledIcon: Icons.dialpad, - disabledIcon: Icons.dialpad, - isDisabled: false, - onToggle: () { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: DialPad( - onDigitPressed: (digit) { - context.read().dtmf(digit); - }, - ), - ); - }, - ); - }, - ), - ], - ), - ], - ); - } -} - -class CallControlButton extends StatefulWidget { - final IconData enabledIcon; - final IconData disabledIcon; - final bool isDisabled; - final VoidCallback onToggle; - - const CallControlButton({ - super.key, - required this.enabledIcon, - required this.disabledIcon, - required this.isDisabled, - required this.onToggle, - }); - - @override - State createState() => _CallControlButtonState(); -} - -class _CallControlButtonState extends State { - @override - Widget build(BuildContext context) { - return Container( - width: iconSize, - height: iconSize, - decoration: BoxDecoration( - color: call_control_color, - shape: BoxShape.circle, - ), - child: IconButton( - icon: - Icon(widget.isDisabled ? widget.disabledIcon : widget.enabledIcon), - onPressed: widget.onToggle, - ), - ); - } -} - -class CallInvitation extends StatelessWidget { - final VoidCallback onAccept; - final VoidCallback onDecline; - - const CallInvitation({ - super.key, - required this.onAccept, - required this.onDecline, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - CallButton( - onPressed: onAccept, - ), - SizedBox(width: spacingM), - DeclineButton( - onPressed: onDecline, - ), - ], - ); - } -} - -abstract class BaseButton extends StatelessWidget { - final VoidCallback onPressed; - final String iconPath; - - const BaseButton({ - super.key, - required this.onPressed, - required this.iconPath, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onPressed, - child: SvgPicture.asset( - iconPath, - width: iconSize, - height: iconSize, - ), - ); - } -} - -class CallButton extends BaseButton { - const CallButton({super.key, required VoidCallback onPressed}) - : super(onPressed: onPressed, iconPath: green_call_icon); -} - -class DeclineButton extends BaseButton { - const DeclineButton({super.key, required VoidCallback onPressed}) - : super(onPressed: onPressed, iconPath: red_decline_icon); -} diff --git a/lib/view/widgets/call_controls/call_invitation.dart b/lib/view/widgets/call_controls/call_invitation.dart new file mode 100644 index 0000000..72cafa0 --- /dev/null +++ b/lib/view/widgets/call_controls/call_invitation.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/buttons/call_buttons.dart'; + +class CallInvitation extends StatelessWidget { + final VoidCallback onAccept; + final VoidCallback onDecline; + + const CallInvitation({ + super.key, + required this.onAccept, + required this.onDecline, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + CallButton( + onPressed: onAccept, + ), + SizedBox(width: spacingM), + DeclineButton( + onPressed: onDecline, + ), + ], + ); + } +} diff --git a/lib/view/widgets/call_controls/ongoing_call_controls.dart b/lib/view/widgets/call_controls/ongoing_call_controls.dart new file mode 100644 index 0000000..4189760 --- /dev/null +++ b/lib/view/widgets/call_controls/ongoing_call_controls.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; +import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/buttons/call_buttons.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/dialpad_widget.dart'; + +class OnGoingCallControls extends StatelessWidget { + const OnGoingCallControls({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + CallControlButton( + enabledIcon: Icons.mic_off, + disabledIcon: Icons.mic, + isDisabled: context.select( + (txClient) => txClient.muteState, + ), + onToggle: () { + context.read().muteUnmute(); + }, + ), + DeclineButton( + onPressed: () { + context.read().endCall(); + }, + ), + CallControlButton( + enabledIcon: Icons.volume_off, + disabledIcon: Icons.volume_up, + isDisabled: context.select( + (txClient) => txClient.speakerPhoneState, + ), + onToggle: () { + context.read().toggleSpeakerPhone(); + }, + ), + ], + ), + SizedBox(height: spacingM), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + CallControlButton( + enabledIcon: Icons.pause, + disabledIcon: Icons.play_arrow, + isDisabled: context.select( + (txClient) => txClient.holdState, + ), + onToggle: () { + context.read().holdUnhold(); + }, + ), + SizedBox(width: iconSize), + CallControlButton( + enabledIcon: Icons.dialpad, + disabledIcon: Icons.dialpad, + isDisabled: false, + onToggle: () { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: DialPad( + onDigitPressed: (digit) { + context.read().dtmf(digit); + }, + ), + ); + }, + ); + }, + ), + ], + ), + ], + ); + } +} diff --git a/lib/view/widgets/header/control_header.dart b/lib/view/widgets/header/control_header.dart index b825a65..14f5a4f 100644 --- a/lib/view/widgets/header/control_header.dart +++ b/lib/view/widgets/header/control_header.dart @@ -20,12 +20,12 @@ class _ControlHeadersState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.symmetric(vertical: 58), + padding: const EdgeInsets.symmetric(vertical: spacingXXXXXXL), child: Center( child: Image.asset( logo_path, - width: 222, - height: 58, + width: logoWidth, + height: logoHeight, ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 20ce740..ef63155 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,12 +22,11 @@ dependencies: flutter_fgbg: ^0.6.0 provider: ^6.1.2 flutter_svg: ^2.0.17 + fluttertoast: ^8.2.10 dev_dependencies: flutter_test: sdk: flutter - flutter_masked_text2: ^0.9.1 - fluttertoast: ^8.0.9 telnyx_webrtc: path: ./packages/telnyx_webrtc From cea1f98f41f7f4109d2aa6bd951b254bcb6f5a60 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 28 Jan 2025 17:12:27 +0000 Subject: [PATCH 20/45] chore: remove unnecessary assets --- assets/icons/loud_speaker.svg | 19 ------------------- assets/icons/mute.svg | 18 ------------------ lib/utils/asset_paths.dart | 2 -- lib/view/telnyx_client_view_model.dart | 2 +- 4 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 assets/icons/loud_speaker.svg delete mode 100644 assets/icons/mute.svg diff --git a/assets/icons/loud_speaker.svg b/assets/icons/loud_speaker.svg deleted file mode 100644 index e627a0a..0000000 --- a/assets/icons/loud_speaker.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/assets/icons/mute.svg b/assets/icons/mute.svg deleted file mode 100644 index 6109000..0000000 --- a/assets/icons/mute.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/lib/utils/asset_paths.dart b/lib/utils/asset_paths.dart index 713d382..cf73f47 100644 --- a/lib/utils/asset_paths.dart +++ b/lib/utils/asset_paths.dart @@ -3,7 +3,5 @@ const logo_path = 'assets/images/telnyx_logo.png'; // SVG Icons const green_call_icon = 'assets/icons/green_call.svg'; const red_decline_icon = 'assets/icons/red_decline.svg'; -const mute_icon = 'assets/icons/mute.svg'; -const loudspeaker_icon = 'assets/icons/loudspeaker.svg'; diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index d2e632b..cf38b15 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -121,7 +121,7 @@ class TelnyxClientViewModel with ChangeNotifier { // TODO: Handle this case. break; case CallState.ringing: - _callState = CallStateStatus.ringing; + _callState = CallStateStatus.ongoingInvitation; notifyListeners(); break; case CallState.active: From 88a5fbe61740a8e1b07a70987e78cd4400d24328 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Wed, 29 Jan 2025 16:02:22 +0000 Subject: [PATCH 21/45] chore: delete login_Screen and show hold / connecting state --- .idea/libraries/Flutter_Plugins.xml | 4 +- lib/view/screen/login_screen.dart | 198 ------------------ lib/view/telnyx_client_view_model.dart | 15 +- .../widgets/call_controls/call_controls.dart | 7 +- .../verto/receive/received_message_body.dart | 9 + 5 files changed, 25 insertions(+), 208 deletions(-) delete mode 100644 lib/view/screen/login_screen.dart diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml index ef281f0..b96399d 100644 --- a/.idea/libraries/Flutter_Plugins.xml +++ b/.idea/libraries/Flutter_Plugins.xml @@ -11,7 +11,6 @@ - @@ -23,9 +22,10 @@ - + + diff --git a/lib/view/screen/login_screen.dart b/lib/view/screen/login_screen.dart deleted file mode 100644 index c97de03..0000000 --- a/lib/view/screen/login_screen.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:telnyx_flutter_webrtc/main.dart'; -import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; -import 'package:provider/provider.dart'; -import 'package:logger/logger.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:telnyx_webrtc/config/telnyx_config.dart'; -import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State with WidgetsBindingObserver { - final logger = Logger(); - TextEditingController sipUserController = TextEditingController(); - TextEditingController sipPasswordController = TextEditingController(); - TextEditingController sipNameController = TextEditingController(); - TextEditingController sipNumberController = TextEditingController(); - CredentialConfig? credentialConfig; - - @override - void initState() { - super.initState(); - - _checkPermissions(); - - // Check if we have logged in before - _checkCredentialsStored().then((value) { - if (!value) { - sipUserController.text = MOCK_USER; - sipPasswordController.text = MOCK_PASSWORD; - } - }); - } - - void _checkPermissions() async { - if (kIsWeb) { - await [ - Permission.audio, - Permission.microphone, - Permission.bluetooth, - Permission.bluetoothConnect, - ].request(); - } else { - await [ - Permission.microphone, - ].request(); - } - } - - Future _attemptLogin() async { - String? token; - if (defaultTargetPlatform == TargetPlatform.android) { - token = (await FirebaseMessaging.instance.getToken())!; - logger.i('Android notification token :: $token'); - } else if (defaultTargetPlatform == TargetPlatform.iOS) { - token = await FlutterCallkitIncoming.getDevicePushTokenVoIP(); - logger.i('iOS notification token :: $token'); - } - credentialConfig = CredentialConfig( - sipUser: sipUserController.text, - sipPassword: sipPasswordController.text, - sipCallerIDName: sipNameController.text, - sipCallerIDNumber: sipNumberController.text, - notificationToken: token, - autoReconnect: true, - debug: false, - ringTonePath: 'assets/audio/incoming_call.mp3', - ringbackPath: 'assets/audio/ringback_tone.mp3', - ); - setState(() { - Provider.of(context, listen: false) - .login(credentialConfig!); - }); - } - - Future _checkCredentialsStored() async { - final prefs = await SharedPreferences.getInstance(); - final sipUser = prefs.getString('sipUser'); - final sipPassword = prefs.getString('sipPassword'); - final sipName = prefs.getString('sipName'); - final sipNumber = prefs.getString('sipNumber'); - if (sipUser != null && sipPassword != null) { - sipUserController.text = sipUser; - sipPasswordController.text = sipPassword; - sipNameController.text = sipName ?? ''; - sipNumberController.text = sipNumber ?? ''; - return true; - } - return false; - } - - Future _saveCredentialsForAutoLogin( - CredentialConfig credentialConfig, - ) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('sipUser', credentialConfig.sipUser); - await prefs.setString('sipPassword', credentialConfig.sipPassword); - await prefs.setString('sipName', credentialConfig.sipCallerIDName); - await prefs.setString('sipNumber', credentialConfig.sipCallerIDNumber); - if (credentialConfig.notificationToken != null) { - await prefs.setString( - 'notificationToken', - credentialConfig.notificationToken!, - ); - } - logger.i('Saved credentials for auto login'); - } - - @override - Widget build(BuildContext context) { - Provider.of(context, listen: true).observeResponses(); - - final bool registered = - Provider.of(context, listen: true).registered; - final bool isLoggingIn = - Provider.of(context, listen: true).loggingIn; - if (registered) { - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.pushReplacementNamed(context, '/home'); - }); - } - - return Scaffold( - appBar: AppBar( - title: Text('Telnyx Login'), - ), - body: isLoggingIn - ? Center( - child: CircularProgressIndicator(), - ) - : Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextFormField( - controller: sipUserController, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'SIP Username', - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: TextFormField( - controller: sipPasswordController, - obscureText: true, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'SIP Password', - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: TextFormField( - controller: sipNameController, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Caller ID Name', - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: TextFormField( - controller: sipNumberController, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Caller ID Number', - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () { - _attemptLogin(); - }, - child: const Text('Login'), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index cf38b15..0ec9b83 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -26,6 +26,8 @@ enum CallStateStatus { idle, ringing, ongoingInvitation, + connectingToCall, + heldCall, ongoingCall, } @@ -115,10 +117,12 @@ class TelnyxClientViewModel with ChangeNotifier { logger.i('Call State :: $state'); switch (state) { case CallState.newCall: - // TODO: Handle this case. + logger.i('New Call'); break; case CallState.connecting: - // TODO: Handle this case. + logger.i('Connecting'); + _callState = CallStateStatus.connectingToCall; + notifyListeners(); break; case CallState.ringing: _callState = CallStateStatus.ongoingInvitation; @@ -130,20 +134,21 @@ class TelnyxClientViewModel with ChangeNotifier { notifyListeners(); if (Platform.isIOS) { // only for iOS - // end Call for Callkit on iOS FlutterCallkitIncoming.setCallConnected(_incomingInvite!.callID!); } break; case CallState.held: - // TODO: Handle this case. + logger.i('Held'); + _callState = CallStateStatus.heldCall; + notifyListeners(); break; case CallState.done: FlutterCallkitIncoming.endCall(currentCall?.callId ?? ''); // TODO: Handle this case. break; case CallState.error: - // TODO: Handle this case. + logger.i('error'); break; } }; diff --git a/lib/view/widgets/call_controls/call_controls.dart b/lib/view/widgets/call_controls/call_controls.dart index b560858..e3d317c 100644 --- a/lib/view/widgets/call_controls/call_controls.dart +++ b/lib/view/widgets/call_controls/call_controls.dart @@ -1,14 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; -import 'package:telnyx_flutter_webrtc/utils/asset_paths.dart'; import 'package:telnyx_flutter_webrtc/utils/dimensions.dart'; import 'package:telnyx_flutter_webrtc/utils/theme.dart'; import 'package:telnyx_flutter_webrtc/view/telnyx_client_view_model.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/buttons/call_buttons.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/call_invitation.dart'; import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/ongoing_call_controls.dart'; -import 'package:telnyx_flutter_webrtc/view/widgets/dialpad_widget.dart'; class CallControls extends StatefulWidget { const CallControls({super.key}); @@ -87,6 +84,10 @@ class _CallControlsState extends State { }, ), ) + else if (clientState == CallStateStatus.connectingToCall) + Center( + child: CircularProgressIndicator(), + ) else if (clientState == CallStateStatus.ongoingCall) Center( child: OnGoingCallControls(), diff --git a/packages/telnyx_webrtc/lib/model/verto/receive/received_message_body.dart b/packages/telnyx_webrtc/lib/model/verto/receive/received_message_body.dart index 61b1d2e..f3c8da3 100644 --- a/packages/telnyx_webrtc/lib/model/verto/receive/received_message_body.dart +++ b/packages/telnyx_webrtc/lib/model/verto/receive/received_message_body.dart @@ -1,4 +1,5 @@ import 'package:logger/logger.dart'; +import 'package:telnyx_webrtc/model/verto/receive/receive_bye_message_body.dart'; import 'package:telnyx_webrtc/model/verto/send/invite_answer_message_body.dart'; import 'package:telnyx_webrtc/model/telnyx_socket_error.dart'; @@ -10,6 +11,7 @@ class ReceivedMessage { StateParams? stateParams; IncomingInviteParams? inviteParams; DialogParams? dialogParams; + ReceiveByeParams? byeParams; String? voiceSdkId; @@ -21,6 +23,7 @@ class ReceivedMessage { this.stateParams, this.inviteParams, this.dialogParams, + this.byeParams, this.voiceSdkId, }); @@ -36,6 +39,9 @@ class ReceivedMessage { inviteParams = json['params'] != null ? IncomingInviteParams.fromJson(json['params']) : null; + byeParams = json['params'] != null + ? ReceiveByeParams.fromJson(json['params']) + : null; if (json['params']['dialogParams'] != null) { dialogParams = DialogParams.fromJson(json['params']['dialogParams']); } @@ -59,6 +65,9 @@ class ReceivedMessage { if (inviteParams != null) { data['params'] = inviteParams!.toJson(); } + if (byeParams != null) { + data['params'] = byeParams!.toJson(); + } if (dialogParams != null) { data['dialogParams'] = dialogParams!.toJson(); } From 3ad6991a5762b1affba1373a594e3a9265ed16d9 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Wed, 29 Jan 2025 16:39:23 +0000 Subject: [PATCH 22/45] fix: [BREAKING CHANGE] no longer specify call ID when ending call --- lib/view/telnyx_client_view_model.dart | 11 +++-------- packages/telnyx_webrtc/lib/call.dart | 9 ++------- packages/telnyx_webrtc/lib/telnyx_client.dart | 4 ++-- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index 0ec9b83..2f18567 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -27,7 +27,6 @@ enum CallStateStatus { ringing, ongoingInvitation, connectingToCall, - heldCall, ongoingCall, } @@ -140,8 +139,6 @@ class TelnyxClientViewModel with ChangeNotifier { break; case CallState.held: logger.i('Held'); - _callState = CallStateStatus.heldCall; - notifyListeners(); break; case CallState.done: FlutterCallkitIncoming.endCall(currentCall?.callId ?? ''); @@ -295,9 +292,7 @@ class TelnyxClientViewModel with ChangeNotifier { currentCall?.callId ?? _incomingInvite!.callID!, ); if (!fromBye) { - _telnyxClient.calls.values.firstOrNull?.endCall( - _incomingInvite?.callID, - ); + _telnyxClient.calls.values.firstOrNull?.endCall(); } // Attempt to end the call if still present and disconnect from the socket to logout - this enables us to receive further push notifications after @@ -481,10 +476,10 @@ class TelnyxClientViewModel with ChangeNotifier { } else { logger.i('end Call: CallfromCallScreen $callFromPush'); // end Call normlly on iOS - currentCall?.endCall(_incomingInvite?.callID); + currentCall?.endCall(); } } else if (Platform.isAndroid || kIsWeb) { - currentCall?.endCall(_incomingInvite?.callID); + currentCall?.endCall(); } _callState = CallStateStatus.idle; diff --git a/packages/telnyx_webrtc/lib/call.dart b/packages/telnyx_webrtc/lib/call.dart index aeac673..328ae11 100644 --- a/packages/telnyx_webrtc/lib/call.dart +++ b/packages/telnyx_webrtc/lib/call.dart @@ -120,14 +120,9 @@ class Call { } /// Attempts to end the call identified via the [callID] - void endCall(String? callID) { - if (callId == null) { - _logger.d('Call ID is null'); - return; - } - + void endCall() { final uuid = const Uuid().v4(); - final byeDialogParams = ByeDialogParams(callId: callID ?? callId); + final byeDialogParams = ByeDialogParams(callId: callId); final byeParams = SendByeParams( cause: CauseCode.USER_BUSY.name, diff --git a/packages/telnyx_webrtc/lib/telnyx_client.dart b/packages/telnyx_webrtc/lib/telnyx_client.dart index 86fcc64..f8ac7b4 100644 --- a/packages/telnyx_webrtc/lib/telnyx_client.dart +++ b/packages/telnyx_webrtc/lib/telnyx_client.dart @@ -921,7 +921,7 @@ class TelnyxClient { .changeState(CallState.active, offerCall); } if (_pendingDeclineFromPush) { - offerCall.endCall(invite.inviteParams?.callID); + offerCall.endCall(); offerCall.callHandler.changeState(CallState.done, offerCall); _pendingDeclineFromPush = false; } @@ -1004,7 +1004,7 @@ class TelnyxClient { _logger.d( 'No SDP provided for Answer or Media, cannot initialize call', ); - answerCall.endCall(inviteAnswer.inviteParams?.callID); + answerCall.endCall(); } _earlySDP = false; answerCall.stopAudio(); From e9c6cf2e4ced8d1f20b0698e8885d7e655586596 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Wed, 29 Jan 2025 16:53:04 +0000 Subject: [PATCH 23/45] fix: [BREAKING CHANGE] remove call ID from dtmf call, use callID from instance --- lib/view/telnyx_client_view_model.dart | 9 +++++---- packages/telnyx_webrtc/lib/call.dart | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/view/telnyx_client_view_model.dart b/lib/view/telnyx_client_view_model.dart index 2f18567..93ac919 100644 --- a/lib/view/telnyx_client_view_model.dart +++ b/lib/view/telnyx_client_view_model.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/entities/notification_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; -import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:logger/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -181,7 +180,8 @@ class TelnyxClientViewModel with ChangeNotifier { _telnyxClient ..onSocketMessageReceived = (TelnyxMessage message) async { logger.i( - 'TxClientViewModel :: observeResponses :: Socket :: ${message.message}'); + 'TxClientViewModel :: observeResponses :: Socket :: ${message.message}', + ); switch (message.socketMethod) { case SocketMethod.clientReady: { @@ -487,7 +487,7 @@ class TelnyxClientViewModel with ChangeNotifier { } void dtmf(String tone) { - currentCall?.dtmf(_telnyxClient.call.callId, tone); + currentCall?.dtmf(tone); } void muteUnmute() { @@ -511,6 +511,7 @@ class TelnyxClientViewModel with ChangeNotifier { void exportLogs() async { final messageLogger = await FileLogger.getInstance(); final logContents = await messageLogger.exportLogs(); - print(logContents); + logger.i('Log Contents :: $logContents'); + //ToDo: Implement log export } } diff --git a/packages/telnyx_webrtc/lib/call.dart b/packages/telnyx_webrtc/lib/call.dart index 328ae11..bffd247 100644 --- a/packages/telnyx_webrtc/lib/call.dart +++ b/packages/telnyx_webrtc/lib/call.dart @@ -167,7 +167,7 @@ class Call { /// Sends a DTMF message with the chosen [tone] to the call /// specified via the [callID] - void dtmf(String? callID, String tone) { + void dtmf(String tone) { final uuid = const Uuid().v4(); final dialogParams = DialogParams( attach: false, From 061542ff085c77726954be5d1e1df679a7ba6e50 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Wed, 29 Jan 2025 17:00:09 +0000 Subject: [PATCH 24/45] chore: version bumps to 1.0.0. Bumping major version as there is a breaking change --- packages/telnyx_webrtc/CHANGELOG.md | 13 +++++++++++-- packages/telnyx_webrtc/pubspec.yaml | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/telnyx_webrtc/CHANGELOG.md b/packages/telnyx_webrtc/CHANGELOG.md index 5d2d01c..d02d195 100644 --- a/packages/telnyx_webrtc/CHANGELOG.md +++ b/packages/telnyx_webrtc/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.0.0](https://pub.dev/packages/telnyx_webrtc/versions/1.0.0) (2025-01-29) + +### Enhancement - Breaking Changes + +- Call ID no longer required when ending call or using DTMF. As these methods belong to a call + object, the call ID is inferred from the call object itself. This means users only need to keep + track of the call objects that are in use and call the relevant methods on the call object itself. + ## [0.1.4](https://pub.dev/packages/telnyx_webrtc/versions/0.1.4) (2025-01-28) ### Enhancement @@ -18,7 +26,8 @@ ### Bug Fixing -- Fixed an issue where, when accepting a an invite, the destination number was being set to name instead of number. +- Fixed an issue where, when accepting a an invite, the destination number was being set to name + instead of number. ## [0.1.1](https://pub.dev/packages/telnyx_webrtc/versions/0.1.1) (2024-12-12) @@ -29,7 +38,7 @@ ### Bug Fixing -- General bug fixes and import cleanups. +- General bug fixes and import cleanups. ## [0.1.0](https://pub.dev/packages/telnyx_webrtc/versions/0.1.0) (2024-11-07) diff --git a/packages/telnyx_webrtc/pubspec.yaml b/packages/telnyx_webrtc/pubspec.yaml index 7c7add3..0728aca 100644 --- a/packages/telnyx_webrtc/pubspec.yaml +++ b/packages/telnyx_webrtc/pubspec.yaml @@ -3,7 +3,7 @@ description: Enable real-time communication with WebRTC and Telnyx. Create and r homepage: https://telnyx.com/ repository: https://github.com/team-telnyx/flutter-voice-sdk issue_tracker: https://github.com/team-telnyx/flutter-voice-sdk/issues -version: 0.1.4 +version: 1.0.0 environment: sdk: ">=3.0.0 <3.22.3" From 068219c9e9c33359aba92c8478839019ce027bc4 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Wed, 29 Jan 2025 17:01:54 +0000 Subject: [PATCH 25/45] chore: add bye param change log for 1.0.0 --- packages/telnyx_webrtc/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/telnyx_webrtc/CHANGELOG.md b/packages/telnyx_webrtc/CHANGELOG.md index d02d195..561931c 100644 --- a/packages/telnyx_webrtc/CHANGELOG.md +++ b/packages/telnyx_webrtc/CHANGELOG.md @@ -6,6 +6,10 @@ object, the call ID is inferred from the call object itself. This means users only need to keep track of the call objects that are in use and call the relevant methods on the call object itself. +### Bug Fixing + +- Fixed an issue where the Bye Params (such as cause = USER_BUSY) were not being included in the ReceivedMessage. + ## [0.1.4](https://pub.dev/packages/telnyx_webrtc/versions/0.1.4) (2025-01-28) ### Enhancement From db4bfa68af776ae72c8b526670fbe40d0979ec23 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Wed, 29 Jan 2025 17:30:57 +0000 Subject: [PATCH 26/45] chore: make inkwell circular on buttons --- lib/view/widgets/call_controls/buttons/call_buttons.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/view/widgets/call_controls/buttons/call_buttons.dart b/lib/view/widgets/call_controls/buttons/call_buttons.dart index 71cd731..77dc53e 100644 --- a/lib/view/widgets/call_controls/buttons/call_buttons.dart +++ b/lib/view/widgets/call_controls/buttons/call_buttons.dart @@ -17,6 +17,7 @@ abstract class BaseButton extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( + customBorder: const CircleBorder(), onTap: onPressed, child: SvgPicture.asset( iconPath, @@ -67,7 +68,7 @@ class _CallControlButtonState extends State { ), child: IconButton( icon: - Icon(widget.isDisabled ? widget.disabledIcon : widget.enabledIcon), + Icon(widget.isDisabled ? widget.disabledIcon : widget.enabledIcon), onPressed: widget.onToggle, ), ); From ac1cace2d2b038e7a7085840c0ccf9f6eaaff05d Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 30 Jan 2025 10:53:28 +0000 Subject: [PATCH 27/45] WEBRTC-2462: Add Flutter UI integration tests - Add integration_test dependency to pubspec.yaml - Create integration test for full call flow - Set up GitHub Actions workflow for UI tests - Use environment variables for secure credentials --- .github/workflows/ui_tests.yml | 34 ++++++++++++++ integration_test/app_test.dart | 82 ++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 + 3 files changed, 118 insertions(+) create mode 100644 .github/workflows/ui_tests.yml create mode 100644 integration_test/app_test.dart diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml new file mode 100644 index 0000000..e81296d --- /dev/null +++ b/.github/workflows/ui_tests.yml @@ -0,0 +1,34 @@ +name: Flutter UI Tests + +on: + pull_request: + branches: [ main ] + +jobs: + integration_test: + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.16.0' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Start Android Emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + arch: x86_64 + profile: Nexus 6 + script: | + flutter test integration_test/app_test.dart \ + --dart-define=APP_LOGIN_USER=${{ secrets.APP_LOGIN_USER }} \ + --dart-define=APP_LOGIN_PASSWORD=${{ secrets.APP_LOGIN_PASSWORD }} \ + --dart-define=APP_LOGIN_NUMBER=${{ secrets.APP_LOGIN_NUMBER }} \ No newline at end of file diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart new file mode 100644 index 0000000..e13979a --- /dev/null +++ b/integration_test/app_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:telnyx_flutter_webrtc/main.dart' as app; +import 'package:telnyx_flutter_webrtc/screens/home_screen.dart'; +import 'package:telnyx_flutter_webrtc/screens/login_screen.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('End-to-End Test', () { + testWidgets('Full call flow test', (WidgetTester tester) async { + // Start the app + app.main(); + await tester.pumpAndSettle(); + + // Create user with debug mode + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + // Get credentials from environment variables + final username = String.fromEnvironment('APP_LOGIN_USER', defaultValue: ''); + final password = String.fromEnvironment('APP_LOGIN_PASSWORD', defaultValue: ''); + final number = String.fromEnvironment('APP_LOGIN_NUMBER', defaultValue: ''); + + // Fill in user details + await tester.enterText(find.byKey(const Key('username_field')), username); + await tester.enterText(find.byKey(const Key('password_field')), password); + await tester.enterText(find.byKey(const Key('number_field')), number); + await tester.enterText(find.byKey(const Key('name_field')), 'Flutter Integration Test User'); + + // Enable debug mode + await tester.tap(find.byKey(const Key('debug_switch'))); + await tester.pumpAndSettle(); + + // Save user + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + // Select user from bottom sheet + await tester.tap(find.text(username)); + await tester.pumpAndSettle(); + + // Wait for connection + await tester.pumpAndSettle(const Duration(seconds: 5)); + + // Enter number to call + await tester.enterText(find.byKey(const Key('phone_number_field')), '18004377950'); + await tester.pumpAndSettle(); + + // Make call + await tester.tap(find.byKey(const Key('call_button'))); + await tester.pumpAndSettle(); + + // Wait for call to be established + await tester.pumpAndSettle(const Duration(seconds: 10)); + + // Test mute functionality + await tester.tap(find.byKey(const Key('mute_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mute_button'))); + await tester.pumpAndSettle(); + + // Test DTMF + await tester.tap(find.byKey(const Key('keypad_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2')); + await tester.pumpAndSettle(); + await tester.tap(find.text('3')); + await tester.pumpAndSettle(); + + // End call + await tester.tap(find.byKey(const Key('end_call_button'))); + await tester.pumpAndSettle(); + + // Verify we're back at the home screen + expect(find.byType(HomeScreen), findsOneWidget); + }); + }); +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index ef63155..fc419dd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter telnyx_webrtc: path: ./packages/telnyx_webrtc From 02f599a90d77dc845c6c22091a89f698216748b6 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 30 Jan 2025 11:34:30 +0000 Subject: [PATCH 28/45] WEBRTC-2462: Update integration tests to match current UI structure --- integration_test/app_test.dart | 47 ++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart index e13979a..443703e 100644 --- a/integration_test/app_test.dart +++ b/integration_test/app_test.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:telnyx_flutter_webrtc/main.dart' as app; -import 'package:telnyx_flutter_webrtc/screens/home_screen.dart'; -import 'package:telnyx_flutter_webrtc/screens/login_screen.dart'; +import 'package:telnyx_flutter_webrtc/view/screen/home_screen.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/buttons/call_buttons.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -24,17 +24,25 @@ void main() { final number = String.fromEnvironment('APP_LOGIN_NUMBER', defaultValue: ''); // Fill in user details - await tester.enterText(find.byKey(const Key('username_field')), username); - await tester.enterText(find.byKey(const Key('password_field')), password); - await tester.enterText(find.byKey(const Key('number_field')), number); - await tester.enterText(find.byKey(const Key('name_field')), 'Flutter Integration Test User'); - - // Enable debug mode - await tester.tap(find.byKey(const Key('debug_switch'))); - await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextFormField, 'SIP Username'), + username, + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'SIP Password'), + password, + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Caller ID Name'), + 'Flutter Integration Test User', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Caller ID Number'), + number, + ); // Save user - await tester.tap(find.text('Save')); + await tester.tap(find.widgetWithText(ElevatedButton, 'Save')); await tester.pumpAndSettle(); // Select user from bottom sheet @@ -45,24 +53,29 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 5)); // Enter number to call - await tester.enterText(find.byKey(const Key('phone_number_field')), '18004377950'); + await tester.enterText( + find.widgetWithText(TextFormField, 'Destination'), + '18004377950', + ); await tester.pumpAndSettle(); // Make call - await tester.tap(find.byKey(const Key('call_button'))); + await tester.tap(find.byType(CallButton)); await tester.pumpAndSettle(); // Wait for call to be established await tester.pumpAndSettle(const Duration(seconds: 10)); // Test mute functionality - await tester.tap(find.byKey(const Key('mute_button'))); + final muteButton = find.byIcon(Icons.mic_off); + await tester.tap(muteButton); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('mute_button'))); + await tester.tap(muteButton); await tester.pumpAndSettle(); // Test DTMF - await tester.tap(find.byKey(const Key('keypad_button'))); + final keypadButton = find.byIcon(Icons.dialpad); + await tester.tap(keypadButton); await tester.pumpAndSettle(); await tester.tap(find.text('1')); await tester.pumpAndSettle(); @@ -72,7 +85,7 @@ void main() { await tester.pumpAndSettle(); // End call - await tester.tap(find.byKey(const Key('end_call_button'))); + await tester.tap(find.byIcon(Icons.call_end)); await tester.pumpAndSettle(); // Verify we're back at the home screen From a3157559caa40ae2f6c2cbaed3c19910031964cb Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Thu, 30 Jan 2025 11:51:09 +0000 Subject: [PATCH 29/45] chore: bump flutter version in action --- .github/workflows/ui_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index e81296d..d918c75 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.16.0' + flutter-version: '3.24.5' channel: 'stable' - name: Install dependencies From 4adf789432015879f86413074b33aa5360a5128b Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Thu, 30 Jan 2025 12:06:44 +0000 Subject: [PATCH 30/45] feat: adjust tests to match actual UI flow --- integration_test/app_test.dart | 74 ++++++++++++++++++++++------------ 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart index 443703e..d4e9e4b 100644 --- a/integration_test/app_test.dart +++ b/integration_test/app_test.dart @@ -10,20 +10,27 @@ void main() { group('End-to-End Test', () { testWidgets('Full call flow test', (WidgetTester tester) async { - // Start the app - app.main(); + // 1. Start the app + await app.main(); await tester.pumpAndSettle(); - // Create user with debug mode - await tester.tap(find.byIcon(Icons.add)); + // 2. Open the bottom sheet by pressing "Switch Profile" + await tester.tap(find.text('Switch Profile')); await tester.pumpAndSettle(); - // Get credentials from environment variables - final username = String.fromEnvironment('APP_LOGIN_USER', defaultValue: ''); - final password = String.fromEnvironment('APP_LOGIN_PASSWORD', defaultValue: ''); - final number = String.fromEnvironment('APP_LOGIN_NUMBER', defaultValue: ''); + // 3. Tap "Add new profile" to show the add-profile form + await tester.tap(find.text('Add new profile')); + await tester.pumpAndSettle(); + + // 4. Retrieve credentials from environment variables + final username = + const String.fromEnvironment('APP_LOGIN_USER', defaultValue: ''); + final password = + const String.fromEnvironment('APP_LOGIN_PASSWORD', defaultValue: ''); + final number = + const String.fromEnvironment('APP_LOGIN_NUMBER', defaultValue: ''); - // Fill in user details + // 5. Fill in SIP details in the bottom sheet await tester.enterText( find.widgetWithText(TextFormField, 'SIP Username'), username, @@ -41,39 +48,49 @@ void main() { number, ); - // Save user - await tester.tap(find.widgetWithText(ElevatedButton, 'Save')); + // 6. Save the new profile + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); - // Select user from bottom sheet - await tester.tap(find.text(username)); - await tester.pumpAndSettle(); + // 7. Tap "Confirm" to close the bottom sheet (if needed) + final confirmButton = find.text('Confirm'); + if (confirmButton.evaluate().isNotEmpty) { + await tester.tap(confirmButton); + await tester.pumpAndSettle(); + } - // Wait for connection + // 8. Now tap "Connect" on the main screen if your UI requires a manual connect: + final connectButton = find.text('Connect'); + if (connectButton.evaluate().isNotEmpty) { + await tester.tap(connectButton); + await tester.pumpAndSettle(); + } + + // 9. Wait a bit for the SIP connection to establish await tester.pumpAndSettle(const Duration(seconds: 5)); - // Enter number to call + // 10. Enter the number to call await tester.enterText( find.widgetWithText(TextFormField, 'Destination'), '18004377950', ); await tester.pumpAndSettle(); - // Make call + // 11. Make the call await tester.tap(find.byType(CallButton)); await tester.pumpAndSettle(); - // Wait for call to be established + // 12. Wait for call to be established await tester.pumpAndSettle(const Duration(seconds: 10)); - // Test mute functionality - final muteButton = find.byIcon(Icons.mic_off); - await tester.tap(muteButton); + // 13. Test Hold/Unhold (tap pause on/off) + final holdButton = find.byIcon(Icons.pause); + await tester.tap(holdButton); await tester.pumpAndSettle(); - await tester.tap(muteButton); + await tester.tap(holdButton); await tester.pumpAndSettle(); - // Test DTMF + // 14. Test DTMF (open keypad & press digits) final keypadButton = find.byIcon(Icons.dialpad); await tester.tap(keypadButton); await tester.pumpAndSettle(); @@ -84,12 +101,17 @@ void main() { await tester.tap(find.text('3')); await tester.pumpAndSettle(); - // End call + // close the keypad + final closeKeypadButton = find.byIcon(Icons.close); + await tester.tap(closeKeypadButton); + await tester.pumpAndSettle(); + + // 15. End call await tester.tap(find.byIcon(Icons.call_end)); await tester.pumpAndSettle(); - // Verify we're back at the home screen + // 16. Verify we're back at the home screen expect(find.byType(HomeScreen), findsOneWidget); }); }); -} \ No newline at end of file +} From f4224c0c477af633ba7ac5a92c4ab021425530cb Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Thu, 30 Jan 2025 16:40:59 +0000 Subject: [PATCH 31/45] feat: migrate to patrol usage to allow us to accept permissions --- .github/workflows/ui_tests.yml | 13 +- android/app/build.gradle | 10 +- .../MainActivityTest.java | 34 +++++ integration_test/app_test.dart | 117 ------------------ integration_test/patrol_test.dart | 114 +++++++++++++++++ integration_test/test_bundle.dart | 86 +++++++++++++ ios/Podfile.lock | 27 ++-- pubspec.yaml | 8 +- 8 files changed, 275 insertions(+), 134 deletions(-) create mode 100644 android/app/src/androidTest/java/com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java delete mode 100644 integration_test/app_test.dart create mode 100644 integration_test/patrol_test.dart create mode 100644 integration_test/test_bundle.dart diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index d918c75..a778fc5 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -11,24 +11,25 @@ jobs: steps: - uses: actions/checkout@v3 - + - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: '3.24.5' channel: 'stable' - + - name: Install dependencies run: flutter pub get - - - name: Start Android Emulator + + - name: Start Android Emulator & Run Tests uses: reactivecircus/android-emulator-runner@v2 with: - api-level: 29 + api-level: 33 arch: x86_64 profile: Nexus 6 script: | - flutter test integration_test/app_test.dart \ + # 1) Run your Patrol test file (replace with correct file path) + flutter test integration_test/patrol_test.dart \ --dart-define=APP_LOGIN_USER=${{ secrets.APP_LOGIN_USER }} \ --dart-define=APP_LOGIN_PASSWORD=${{ secrets.APP_LOGIN_PASSWORD }} \ --dart-define=APP_LOGIN_NUMBER=${{ secrets.APP_LOGIN_NUMBER }} \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index a67844b..d784e46 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -26,7 +26,7 @@ if (flutterVersionName == null) { android { namespace 'com.telnyx.telnyx_flutter_webrtc' ndkVersion "25.1.8937393" - compileSdkVersion 34 + compileSdkVersion 35 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -49,6 +49,13 @@ android { targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName + + testInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner" + testInstrumentationRunnerArguments clearPackageData: "true" + } + + testOptions { + execution "ANDROIDX_TEST_ORCHESTRATOR" } buildTypes { @@ -67,4 +74,5 @@ flutter { dependencies { implementation platform('com.google.firebase:firebase-bom:30.4.1') + androidTestUtil "androidx.test:orchestrator:1.5.1" } diff --git a/android/app/src/androidTest/java/com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java b/android/app/src/androidTest/java/com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java new file mode 100644 index 0000000..5b45107 --- /dev/null +++ b/android/app/src/androidTest/java/com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java @@ -0,0 +1,34 @@ +package com.telnyx.telnyx_flutter_webrtc; + +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import pl.leancode.patrol.PatrolJUnitRunner; + +@RunWith(Parameterized.class) +public class MainActivityTest { + @Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + // replace "MainActivity.class" with "io.flutter.embedding.android.FlutterActivity.class" + // if in AndroidManifest.xml in manifest/application/activity you have + // android:name="io.flutter.embedding.android.FlutterActivity" + instrumentation.setUp(MainActivity.class); + instrumentation.waitForPatrolAppService(); + return instrumentation.listDartTests(); + } + + public MainActivityTest(String dartTestName) { + this.dartTestName = dartTestName; + } + + private final String dartTestName; + + @Test + public void runDartTest() { + PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.runDartTest(dartTestName); + } +} \ No newline at end of file diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart deleted file mode 100644 index d4e9e4b..0000000 --- a/integration_test/app_test.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:telnyx_flutter_webrtc/main.dart' as app; -import 'package:telnyx_flutter_webrtc/view/screen/home_screen.dart'; -import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/buttons/call_buttons.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('End-to-End Test', () { - testWidgets('Full call flow test', (WidgetTester tester) async { - // 1. Start the app - await app.main(); - await tester.pumpAndSettle(); - - // 2. Open the bottom sheet by pressing "Switch Profile" - await tester.tap(find.text('Switch Profile')); - await tester.pumpAndSettle(); - - // 3. Tap "Add new profile" to show the add-profile form - await tester.tap(find.text('Add new profile')); - await tester.pumpAndSettle(); - - // 4. Retrieve credentials from environment variables - final username = - const String.fromEnvironment('APP_LOGIN_USER', defaultValue: ''); - final password = - const String.fromEnvironment('APP_LOGIN_PASSWORD', defaultValue: ''); - final number = - const String.fromEnvironment('APP_LOGIN_NUMBER', defaultValue: ''); - - // 5. Fill in SIP details in the bottom sheet - await tester.enterText( - find.widgetWithText(TextFormField, 'SIP Username'), - username, - ); - await tester.enterText( - find.widgetWithText(TextFormField, 'SIP Password'), - password, - ); - await tester.enterText( - find.widgetWithText(TextFormField, 'Caller ID Name'), - 'Flutter Integration Test User', - ); - await tester.enterText( - find.widgetWithText(TextFormField, 'Caller ID Number'), - number, - ); - - // 6. Save the new profile - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - // 7. Tap "Confirm" to close the bottom sheet (if needed) - final confirmButton = find.text('Confirm'); - if (confirmButton.evaluate().isNotEmpty) { - await tester.tap(confirmButton); - await tester.pumpAndSettle(); - } - - // 8. Now tap "Connect" on the main screen if your UI requires a manual connect: - final connectButton = find.text('Connect'); - if (connectButton.evaluate().isNotEmpty) { - await tester.tap(connectButton); - await tester.pumpAndSettle(); - } - - // 9. Wait a bit for the SIP connection to establish - await tester.pumpAndSettle(const Duration(seconds: 5)); - - // 10. Enter the number to call - await tester.enterText( - find.widgetWithText(TextFormField, 'Destination'), - '18004377950', - ); - await tester.pumpAndSettle(); - - // 11. Make the call - await tester.tap(find.byType(CallButton)); - await tester.pumpAndSettle(); - - // 12. Wait for call to be established - await tester.pumpAndSettle(const Duration(seconds: 10)); - - // 13. Test Hold/Unhold (tap pause on/off) - final holdButton = find.byIcon(Icons.pause); - await tester.tap(holdButton); - await tester.pumpAndSettle(); - await tester.tap(holdButton); - await tester.pumpAndSettle(); - - // 14. Test DTMF (open keypad & press digits) - final keypadButton = find.byIcon(Icons.dialpad); - await tester.tap(keypadButton); - await tester.pumpAndSettle(); - await tester.tap(find.text('1')); - await tester.pumpAndSettle(); - await tester.tap(find.text('2')); - await tester.pumpAndSettle(); - await tester.tap(find.text('3')); - await tester.pumpAndSettle(); - - // close the keypad - final closeKeypadButton = find.byIcon(Icons.close); - await tester.tap(closeKeypadButton); - await tester.pumpAndSettle(); - - // 15. End call - await tester.tap(find.byIcon(Icons.call_end)); - await tester.pumpAndSettle(); - - // 16. Verify we're back at the home screen - expect(find.byType(HomeScreen), findsOneWidget); - }); - }); -} diff --git a/integration_test/patrol_test.dart b/integration_test/patrol_test.dart new file mode 100644 index 0000000..5ae3028 --- /dev/null +++ b/integration_test/patrol_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; +import 'package:telnyx_flutter_webrtc/main.dart'; +import 'package:telnyx_flutter_webrtc/view/screen/home_screen.dart'; +import 'package:telnyx_flutter_webrtc/view/widgets/call_controls/buttons/call_buttons.dart'; + +void main() { + patrolTest( + 'Full call flow test', + framePolicy: LiveTestWidgetsFlutterBindingFramePolicy.fullyLive, + ($) async { + // 1. Start the app using Patrol + await $.pumpWidgetAndSettle(const MyApp()); + await $.native.grantPermissionWhenInUse(); + + // 2. Open the bottom sheet by pressing "Switch Profile" + await $('Switch Profile').tap(); + await $.pumpAndSettle(); + + // 3. Tap "Add new profile" + await $('Add new profile').tap(); + await $.pumpAndSettle(); + + // 4. Retrieve credentials from environment variables + final username = const String.fromEnvironment( + 'APP_LOGIN_USER', + defaultValue: 'testUser', + ); + final password = const String.fromEnvironment( + 'APP_LOGIN_PASSWORD', + defaultValue: 'testPassword', + ); + final number = const String.fromEnvironment( + 'APP_LOGIN_NUMBER', + defaultValue: 'testNumber', + ); + + // 5. Fill in SIP details in the bottom sheet + await $(TextFormField).at(0).enterText(username); + await $(TextFormField).at(1).enterText(password); + await $(TextFormField).at(2).enterText('Flutter Integration Test User'); + await $(TextFormField).at(3).enterText(number); + + // 6. Save the new profile + await $('Save').tap(); + await $.pumpAndSettle(); + + // 7. Select the newly added profile by tapping its display text + await $('Flutter Integration Test User').tap(); + await $.pumpAndSettle(); + + // 8. Tap "Confirm" to close bottom sheet, if it's present + final confirmButton = $('Confirm'); + if (confirmButton.exists) { + await confirmButton.tap(); + await $.pumpAndSettle(); + } + + // 9. Tap "Connect" if your UI requires a manual connect + final connectButton = $('Connect'); + if (connectButton.exists) { + await connectButton.tap(); + await $.pumpAndSettle(); + } + + // 10. Wait a bit for the SIP connection + await Future.delayed(const Duration(seconds: 5)); + await $.pumpAndSettle(); + + // 11. Enter the number to call + await $(TextFormField).at(0).enterText('18004377950'); + await $.pumpAndSettle(); + + // 12. Make the call + // CallButton is a custom widget, so we'll use $.tester.tap(find.byType(...)) + await $.tester.tap(find.byType(CallButton)); + await $.pumpAndSettle(); + await $.native.grantPermissionWhenInUse(); + + // 13. Wait for call to be established + await Future.delayed(const Duration(seconds: 10)); + await $.pumpAndSettle(); + + // 14. Test Hold/Unhold (tap pause on/off) + await $.tester.tap(find.byIcon(Icons.pause)); + await $.pumpAndSettle(); + await $.tester.tap(find.byIcon(Icons.play_arrow)); + await $.pumpAndSettle(); + + // 15. Test DTMF (open keypad & press digits) + await $.tester.tap(find.byIcon(Icons.dialpad)); + await $.pumpAndSettle(); + // You can tap digits by text, if they're displayed as text: + await $('1').tap(); + await $.pumpAndSettle(); + await $('2').tap(); + await $.pumpAndSettle(); + await $('3').tap(); + await $.pumpAndSettle(); + + // Close the keypad + await $.tester.tap(find.byIcon(Icons.close)); + await $.pumpAndSettle(); + + // 16. End call + await $.tester.tap(find.byType(DeclineButton)); + await $.pumpAndSettle(); + + // 17. Verify we're back at HomeScreen + expect(find.byType(HomeScreen), findsOneWidget); + }, + ); +} diff --git a/integration_test/test_bundle.dart b/integration_test/test_bundle.dart new file mode 100644 index 0000000..2df64d5 --- /dev/null +++ b/integration_test/test_bundle.dart @@ -0,0 +1,86 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND AND DO NOT COMMIT TO VERSION CONTROL +// ignore_for_file: type=lint, invalid_use_of_internal_member + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; +import 'package:patrol/src/native/contracts/contracts.dart'; +import 'package:test_api/src/backend/invoker.dart'; + +// START: GENERATED TEST IMPORTS +import 'patrol_test.dart' as patrol_test; +// END: GENERATED TEST IMPORTS + +Future main() async { + // This is the entrypoint of the bundled Dart test. + // + // Its responsibilies are: + // * Running a special Dart test that runs before all the other tests and + // explores the hierarchy of groups and tests. + // * Hosting a PatrolAppService, which the native side of Patrol uses to get + // the Dart tests, and to request execution of a specific Dart test. + // + // When running on Android, the Android Test Orchestrator, before running the + // tests, makes an initial run to gather the tests that it will later run. The + // native side of Patrol (specifically: PatrolJUnitRunner class) is hooked + // into the Android Test Orchestrator lifecycle and knows when that initial + // run happens. When it does, PatrolJUnitRunner makes an RPC call to + // PatrolAppService and asks it for Dart tests. + // + // When running on iOS, the native side of Patrol (specifically: the + // PATROL_INTEGRATION_TEST_IOS_RUNNER macro) makes an initial run to gather + // the tests that it will later run (same as the Android). During that initial + // run, it makes an RPC call to PatrolAppService and asks it for Dart tests. + // + // Once the native runner has the list of Dart tests, it dynamically creates + // native test cases from them. On Android, this is done using the + // Parametrized JUnit runner. On iOS, new test case methods are swizzled into + // the RunnerUITests class, taking advantage of the very dynamic nature of + // Objective-C runtime. + // + // Execution of these dynamically created native test cases is then fully + // managed by the underlying native test framework (JUnit on Android, XCTest + // on iOS). The native test cases do only one thing - request execution of the + // Dart test (out of which they had been created) and wait for it to complete. + // The result of running the Dart test is the result of the native test case. + + final nativeAutomator = NativeAutomator(config: NativeAutomatorConfig()); + await nativeAutomator.initialize(); + final binding = PatrolBinding.ensureInitialized(NativeAutomatorConfig()); + final testExplorationCompleter = Completer(); + + // A special test to explore the hierarchy of groups and tests. This is a hack + // around https://github.com/dart-lang/test/issues/1998. + // + // This test must be the first to run. If not, the native side likely won't + // receive any tests, and everything will fall apart. + test('patrol_test_explorer', () { + // Maybe somewhat counterintuitively, this callback runs *after* the calls + // to group() below. + final topLevelGroup = Invoker.current!.liveTest.groups.first; + final dartTestGroup = createDartTestGroup(topLevelGroup, + tags: null, + excludeTags: null, + ); + testExplorationCompleter.complete(dartTestGroup); + print('patrol_test_explorer: obtained Dart-side test hierarchy:'); + reportGroupStructure(dartTestGroup); + }); + + // START: GENERATED TEST GROUPS + group('patrol_test', patrol_test.main); + // END: GENERATED TEST GROUPS + + final dartTestGroup = await testExplorationCompleter.future; + final appService = PatrolAppService(topLevelDartTestGroup: dartTestGroup); + binding.patrolAppService = appService; + await runAppService(appService); + + // Until now, the native test runner was waiting for us, the Dart side, to + // come alive. Now that we did, let's tell it that we're ready to be asked + // about Dart tests. + await nativeAutomator.markPatrolAppServiceReady(); + + await appService.testExecutionCompleted; +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4edaf73..9cceb57 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,6 +1,7 @@ PODS: - audio_session (0.0.1): - Flutter + - CocoaAsyncSocket (7.6.5) - connectivity_plus (0.0.1): - Flutter - FlutterMacOS @@ -43,9 +44,7 @@ PODS: - Flutter - flutter_fgbg (0.0.1): - Flutter - - flutter_native_splash (2.4.3): - - Flutter - - flutter_webrtc (0.12.2): + - flutter_webrtc (0.12.6): - Flutter - WebRTC-SDK (= 125.6422.06) - fluttertoast (0.0.2): @@ -78,6 +77,8 @@ PODS: - GoogleUtilities/UserDefaults (8.0.2): - GoogleUtilities/Logger - GoogleUtilities/Privacy + - integration_test (0.0.1): + - Flutter - just_audio (0.0.1): - Flutter - nanopb (3.30910.0): @@ -88,6 +89,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - patrol (0.0.1): + - CocoaAsyncSocket (~> 7.6) + - Flutter + - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - PromisesObjC (2.4.0) @@ -105,16 +110,18 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`) - flutter_fgbg (from `.symlinks/plugins/flutter_fgbg/ios`) - - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - just_audio (from `.symlinks/plugins/just_audio/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - patrol (from `.symlinks/plugins/patrol/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) SPEC REPOS: trunk: + - CocoaAsyncSocket - CryptoSwift - Firebase - FirebaseCore @@ -143,16 +150,18 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_callkit_incoming/ios" flutter_fgbg: :path: ".symlinks/plugins/flutter_fgbg/ios" - flutter_native_splash: - :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_webrtc: :path: ".symlinks/plugins/flutter_webrtc/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" just_audio: :path: ".symlinks/plugins/just_audio/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + patrol: + :path: ".symlinks/plugins/patrol/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" shared_preferences_foundation: @@ -160,6 +169,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207 + CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695 CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90 Firebase: 374a441a91ead896215703a674d58cdb3e9d772b @@ -172,14 +182,15 @@ SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_callkit_incoming: 417dd1b46541cdd5d855ad795ccbe97d1c18155e flutter_fgbg: 31c0d1140a131daea2d342121808f6aa0dcd879d - flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a - flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563 + flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1 fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 just_audio: baa7252489dbcf47a4c7cc9ca663e9661c99aafa nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + patrol: 0564cee315ff6c86fb802b3647db05cc2d3d0624 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 diff --git a/pubspec.yaml b/pubspec.yaml index fc419dd..c137d5a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,6 @@ dependencies: permission_handler: ^11.3.1 connectivity_plus: ^6.1.0 path_provider: ^2.1.5 - flutter_fgbg: ^0.6.0 provider: ^6.1.2 flutter_svg: ^2.0.17 fluttertoast: ^8.2.10 @@ -29,10 +28,10 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter + patrol: ^3.14.0 telnyx_webrtc: path: ./packages/telnyx_webrtc - flutter: uses-material-design: true @@ -42,3 +41,8 @@ flutter: - assets/launcher.png - assets/images/ - assets/icons/ + +patrol: + app_name: Telnyx Flutter WebRTC + android: + package_name: com.telnyx.telnyx_flutter_webrtc From a53309dfc6f1ce77d23ed108a76574a8d68fce75 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Thu, 30 Jan 2025 17:10:33 +0000 Subject: [PATCH 32/45] feat: add workflow dispatch to run manually as well as use Patrol not flutter test --- .github/workflows/ui_tests.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index a778fc5..30d551c 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -3,6 +3,7 @@ name: Flutter UI Tests on: pull_request: branches: [ main ] + workflow_dispatch: jobs: integration_test: @@ -12,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup Flutter + - name: Set up Flutter uses: subosito/flutter-action@v2 with: flutter-version: '3.24.5' @@ -21,15 +22,18 @@ jobs: - name: Install dependencies run: flutter pub get - - name: Start Android Emulator & Run Tests + - name: Install Patrol CLI + run: flutter pub global activate patrol_cli + + - name: Start Android Emulator & Run Patrol uses: reactivecircus/android-emulator-runner@v2 with: - api-level: 33 + api-level: 33 # or whichever API level you need arch: x86_64 profile: Nexus 6 script: | - # 1) Run your Patrol test file (replace with correct file path) - flutter test integration_test/patrol_test.dart \ + patrol test \ + -t integration_test/patrol_test.dart \ --dart-define=APP_LOGIN_USER=${{ secrets.APP_LOGIN_USER }} \ --dart-define=APP_LOGIN_PASSWORD=${{ secrets.APP_LOGIN_PASSWORD }} \ --dart-define=APP_LOGIN_NUMBER=${{ secrets.APP_LOGIN_NUMBER }} \ No newline at end of file From 7946143a595db12cb7f408fc2a848c0abc2cea77 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Thu, 30 Jan 2025 17:34:24 +0000 Subject: [PATCH 33/45] chore: remove unnecessary comment --- .github/workflows/ui_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 30d551c..7af488f 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -28,7 +28,7 @@ jobs: - name: Start Android Emulator & Run Patrol uses: reactivecircus/android-emulator-runner@v2 with: - api-level: 33 # or whichever API level you need + api-level: 33 arch: x86_64 profile: Nexus 6 script: | From 3111371ba173e306d6a516261339904d5de06990 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Thu, 30 Jan 2025 17:50:25 +0000 Subject: [PATCH 34/45] chore: remove more unnecessary comments --- .../com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/android/app/src/androidTest/java/com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java b/android/app/src/androidTest/java/com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java index 5b45107..5fb4c02 100644 --- a/android/app/src/androidTest/java/com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java +++ b/android/app/src/androidTest/java/com/telnyx/telnyx_flutter_webrtc/MainActivityTest.java @@ -12,9 +12,6 @@ public class MainActivityTest { @Parameters(name = "{0}") public static Object[] testCases() { PatrolJUnitRunner instrumentation = (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); - // replace "MainActivity.class" with "io.flutter.embedding.android.FlutterActivity.class" - // if in AndroidManifest.xml in manifest/application/activity you have - // android:name="io.flutter.embedding.android.FlutterActivity" instrumentation.setUp(MainActivity.class); instrumentation.waitForPatrolAppService(); return instrumentation.listDartTests(); From bde4563043319b59640bf6283e52b423ea4f5893 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Fri, 31 Jan 2025 15:31:02 +0000 Subject: [PATCH 35/45] chore: we don't need to specify current platform - this means our CI can run with the missing firebase_options from git itgnore --- lib/main.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index e74e1d3..1aa1819 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,7 +19,6 @@ import 'package:telnyx_webrtc/telnyx_client.dart'; import 'package:telnyx_webrtc/model/telnyx_message.dart'; import 'package:telnyx_webrtc/model/socket_method.dart'; import 'package:telnyx_flutter_webrtc/utils/theme.dart'; -import 'package:telnyx_flutter_webrtc/firebase_options.dart'; final logger = Logger(); final txClientViewModel = TelnyxClientViewModel(); @@ -136,9 +135,7 @@ class AppInitializer { } /// Firebase - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); + await Firebase.initializeApp(); if (Platform.isAndroid) { FirebaseMessaging.onBackgroundMessage( _firebaseMessagingBackgroundHandler, From 149cf4d86ef68568ead06c63571f061c08eaebec Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Fri, 31 Jan 2025 16:00:26 +0000 Subject: [PATCH 36/45] feat: use secret google-services.json --- .github/workflows/ui_tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 7af488f..0a32f73 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -13,6 +13,9 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Create google-services.json + run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" > android/app/google-services.json + - name: Set up Flutter uses: subosito/flutter-action@v2 with: From 348bd251827059e1cf47bd1fd7a3ee197d4b0b56 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Fri, 31 Jan 2025 16:33:26 +0000 Subject: [PATCH 37/45] chore: use heredoc to replace json --- .github/workflows/ui_tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 0a32f73..42a1aeb 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -14,7 +14,10 @@ jobs: - uses: actions/checkout@v3 - name: Create google-services.json - run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" > android/app/google-services.json + run: | + cat < android/app/google-services.json + ${{ secrets.GOOGLE_SERVICES_JSON }} + EOF - name: Set up Flutter uses: subosito/flutter-action@v2 From edfa883bdac7444d478adcd045d43c336d8a12b6 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 4 Feb 2025 13:42:43 +0000 Subject: [PATCH 38/45] feat: use firebase test labs instead --- .github/workflows/ui_tests.yml | 50 +++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 42a1aeb..f7efcdc 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -1,4 +1,4 @@ -name: Flutter UI Tests +name: Flutter UI Tests (Firebase Test Lab) on: pull_request: @@ -7,39 +7,57 @@ on: jobs: integration_test: - name: Integration Tests runs-on: ubuntu-latest - steps: + # 1) Check out repo - uses: actions/checkout@v3 + # 2) Create google-services.json if you have it in a secret - name: Create google-services.json run: | cat < android/app/google-services.json - ${{ secrets.GOOGLE_SERVICES_JSON }} - EOF + ${{ secrets.GOOGLE_SERVICES_JSON }} + EOF + # 3) Install Flutter - name: Set up Flutter uses: subosito/flutter-action@v2 with: flutter-version: '3.24.5' channel: 'stable' + # 4) Install Dependencies - name: Install dependencies run: flutter pub get + # 5) Install Patrol CLI - name: Install Patrol CLI run: flutter pub global activate patrol_cli - - name: Start Android Emulator & Run Patrol - uses: reactivecircus/android-emulator-runner@v2 + # 6) Install Google Cloud SDK + - name: Install Google Cloud SDK + uses: google-github-actions/setup-gcloud@v1 with: - api-level: 33 - arch: x86_64 - profile: Nexus 6 - script: | - patrol test \ - -t integration_test/patrol_test.dart \ - --dart-define=APP_LOGIN_USER=${{ secrets.APP_LOGIN_USER }} \ - --dart-define=APP_LOGIN_PASSWORD=${{ secrets.APP_LOGIN_PASSWORD }} \ - --dart-define=APP_LOGIN_NUMBER=${{ secrets.APP_LOGIN_NUMBER }} \ No newline at end of file + service_account_key: ${{ secrets.GCLOUD_SERVICE_ACCOUNT_KEY }} + project_id: ${{ secrets.GCLOUD_PROJECT_ID }} + + # 7) Build app & test APK + - name: Build app & test APK + run: | + # Example approach with flutter directly (not always 1:1 with Patrol). + flutter build apk --debug + flutter build apk --debug \ + --target=integration_test/patrol_test.dart \ + -t test_bundle.dart \ + --split-debug-info=build/app/intermediates/symbols + + # 8) Run tests on Firebase Test Lab + - name: Run Tests on Firebase Test Lab + run: | + # Typically you define your device matrix, e.g. a Pixel 5, API 33 + gcloud firebase test android run \ + --type instrumentation \ + --app build/app/outputs/flutter-apk/app-debug.apk \ + --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ + --device model=Pixel5,version=33,locale=en,orientation=portrait \ + --timeout 5m From fc2ecddc2640e58ca869eee7bc9252442f4007bd Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 4 Feb 2025 14:01:55 +0000 Subject: [PATCH 39/45] chore: adjust build script for UI tests --- .github/workflows/ui_tests.yml | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index f7efcdc..d5e6643 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -44,20 +44,17 @@ jobs: # 7) Build app & test APK - name: Build app & test APK run: | - # Example approach with flutter directly (not always 1:1 with Patrol). - flutter build apk --debug - flutter build apk --debug \ - --target=integration_test/patrol_test.dart \ - -t test_bundle.dart \ - --split-debug-info=build/app/intermediates/symbols + patrol build android --target integration_test/example_test.dart # 8) Run tests on Firebase Test Lab - name: Run Tests on Firebase Test Lab run: | - # Typically you define your device matrix, e.g. a Pixel 5, API 33 gcloud firebase test android run \ - --type instrumentation \ - --app build/app/outputs/flutter-apk/app-debug.apk \ - --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ - --device model=Pixel5,version=33,locale=en,orientation=portrait \ - --timeout 5m + --type instrumentation \ + --use-orchestrator \ + --app build/app/outputs/apk/debug/app-debug.apk \ + --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ + --timeout 1m \ + --device model=MediumPhone.arm,version=34,locale=en,orientation=portrait \ + --record-video \ + --environment-variables clearPackageData=true From 17b44b728847909e951192a4d3747854eabf9fe8 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 4 Feb 2025 14:04:33 +0000 Subject: [PATCH 40/45] fix: target correct tests --- .github/workflows/ui_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index d5e6643..c020189 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -44,7 +44,7 @@ jobs: # 7) Build app & test APK - name: Build app & test APK run: | - patrol build android --target integration_test/example_test.dart + patrol build android --target integration_test/patrol_test.dart # 8) Run tests on Firebase Test Lab - name: Run Tests on Firebase Test Lab From c7800da8447f7bba29f832e164a3befb0c864145 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 4 Feb 2025 14:39:47 +0000 Subject: [PATCH 41/45] fix: remove indentation --- .github/workflows/ui_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index c020189..556b651 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -16,8 +16,8 @@ jobs: - name: Create google-services.json run: | cat < android/app/google-services.json - ${{ secrets.GOOGLE_SERVICES_JSON }} - EOF + ${{ secrets.GOOGLE_SERVICES_JSON }} + EOF # 3) Install Flutter - name: Set up Flutter From c2a3854bc3353ab96d9b5d4d01c36322795e20ab Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 4 Feb 2025 14:51:38 +0000 Subject: [PATCH 42/45] chore: add --verbose to help debug during CI run --- .github/workflows/ui_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 556b651..24859ce 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -44,7 +44,7 @@ jobs: # 7) Build app & test APK - name: Build app & test APK run: | - patrol build android --target integration_test/patrol_test.dart + patrol build android --target integration_test/patrol_test.dart --verbose # 8) Run tests on Firebase Test Lab - name: Run Tests on Firebase Test Lab From 4972f6e4776d319b3586b072ae9481758bdbf802 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 4 Feb 2025 15:41:26 +0000 Subject: [PATCH 43/45] chore: export default credentials from GCLOUD --- .github/workflows/ui_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 24859ce..8bab079 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -40,6 +40,7 @@ jobs: with: service_account_key: ${{ secrets.GCLOUD_SERVICE_ACCOUNT_KEY }} project_id: ${{ secrets.GCLOUD_PROJECT_ID }} + export_default_credentials: true # 7) Build app & test APK - name: Build app & test APK From 63cba39d8b235410c18ea8373cd9fbf4811726f8 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 4 Feb 2025 17:23:04 +0000 Subject: [PATCH 44/45] chore: gcloud auth v2 --- .github/workflows/ui_tests.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 8bab079..6a178ac 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -34,13 +34,14 @@ jobs: - name: Install Patrol CLI run: flutter pub global activate patrol_cli - # 6) Install Google Cloud SDK - - name: Install Google Cloud SDK - uses: google-github-actions/setup-gcloud@v1 + # 6) Authenticate Cloud SDK + - name: 'Authenticate Cloud SDK' + uses: 'google-github-actions/auth@v2' with: - service_account_key: ${{ secrets.GCLOUD_SERVICE_ACCOUNT_KEY }} - project_id: ${{ secrets.GCLOUD_PROJECT_ID }} - export_default_credentials: true + credentials_json: '${{ secrets.GCLOUD_SERVICE_ACCOUNT_KEY }}' + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 # 7) Build app & test APK - name: Build app & test APK From f69d1f95e868a447d685b1144f5509300e5ed944 Mon Sep 17 00:00:00 2001 From: Oliver Zimmerman Date: Tue, 4 Feb 2025 18:21:32 +0000 Subject: [PATCH 45/45] feat: add secrets for username, password and number --- .github/workflows/ui_tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index 6a178ac..d87b7ed 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -46,7 +46,12 @@ jobs: # 7) Build app & test APK - name: Build app & test APK run: | - patrol build android --target integration_test/patrol_test.dart --verbose + patrol build android \ + --target integration_test/patrol_test.dart \ + --dart-define=APP_LOGIN_USER=${{ secrets.APP_LOGIN_USER }} \ + --dart-define=APP_LOGIN_PASSWORD=${{ secrets.APP_LOGIN_PASSWORD }} \ + --dart-define=APP_LOGIN_NUMBER=${{ secrets.APP_LOGIN_NUMBER }} \ + --verbose # 8) Run tests on Firebase Test Lab - name: Run Tests on Firebase Test Lab