From d0014e5cc9f73998e5a0d801bf17f08980442beb Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Wed, 23 Aug 2023 00:27:56 +0200 Subject: [PATCH 01/35] feat: add passcode to home --- lib/blocs/home_add_view_bloc.dart | 10 +--------- lib/blocs/home_view_bloc.dart | 31 +++++++++++++++++++++++++++++++ lib/models/home.dart | 13 ++++++++++--- lib/states/home_add_state.dart | 11 +++++------ lib/states/home_state.dart | 2 ++ lib/views/call_view.dart | 25 +++++++++++++++++++++++-- lib/views/home_add_view.dart | 15 +++++++++++---- 7 files changed, 83 insertions(+), 24 deletions(-) diff --git a/lib/blocs/home_add_view_bloc.dart b/lib/blocs/home_add_view_bloc.dart index f44a731..bae2bde 100644 --- a/lib/blocs/home_add_view_bloc.dart +++ b/lib/blocs/home_add_view_bloc.dart @@ -42,19 +42,10 @@ class HomeAddViewBloc extends Bloc { "Please enter a channel prefix within format 'com.dieklingel/main/prefix/'"; } - String? signError; - RegExp signRegex = RegExp( - r'^[A-Za-z]+$', - ); - if (!signRegex.hasMatch(event.sign)) { - signError = "Please enter a sign within the format 'mysign'"; - } - HomeAddFormErrorState errorState = HomeAddFormErrorState( nameError: nameError, serverError: serverError, channelError: channelError, - signError: signError, ); if (errorState.hasError) { emit(errorState); @@ -71,6 +62,7 @@ class HomeAddViewBloc extends Bloc { home.uri = uri; home.username = event.username; home.password = event.password; + home.passcode = event.passcode; emit(HomeAddLoadingState()); final client = MqttClient(home.uri); diff --git a/lib/blocs/home_view_bloc.dart b/lib/blocs/home_view_bloc.dart index e2ffe06..ae08ef3 100644 --- a/lib/blocs/home_view_bloc.dart +++ b/lib/blocs/home_view_bloc.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'package:dieklingel_app/models/hive_home.dart'; +import 'package:dieklingel_app/models/request.dart'; import 'package:dieklingel_app/repositories/home_repository.dart'; import 'package:dieklingel_app/states/home_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mqtt/mqtt.dart'; +import 'package:path/path.dart' as path; class HomeViewBloc extends Bloc { final HomeRepository homeRepository; @@ -11,6 +14,7 @@ class HomeViewBloc extends Bloc { HomeViewBloc(this.homeRepository) : super(HomeState()) { on(_onSelected); on(_onRefresh); + on(_onUnlock); add(HomeRefresh()); } @@ -39,4 +43,31 @@ class HomeViewBloc extends Bloc { ); } } + + Future _onUnlock(HomeUnlock event, Emitter emit) async { + HiveHome? home = homeRepository.selected; + if (home == null) { + return; + } + + MqttClient client = MqttClient(home.uri); + try { + await client.connect( + username: home.username ?? "", + password: home.password ?? "", + ); + } catch (e) { + print("error ${e.toString()}"); + return; + } + + await client.publish( + path.normalize("./${home.uri.path}/actions/execute"), + Request.withJsonBody("GET", { + "pattern": "unlock", + "environment": { + "PASSCODE": home.passcode, + } + }).toJsonString()); + } } diff --git a/lib/models/home.dart b/lib/models/home.dart index 89a0738..2f82fcc 100644 --- a/lib/models/home.dart +++ b/lib/models/home.dart @@ -3,12 +3,14 @@ class Home { Uri uri; String? username; String? password; + String? passcode; Home({ required this.name, required this.uri, this.username, this.password, + this.passcode, }); factory Home.fromMap(Map map) { @@ -24,6 +26,7 @@ class Home { uri: Uri.parse(map["uri"]), username: map["username"], password: map["password"], + passcode: map["passcode"], ); } @@ -33,6 +36,7 @@ class Home { "uri": uri.toString(), "username": username, "password": password, + "passcode": passcode, }; } @@ -45,12 +49,14 @@ class Home { Uri? uri, String? username, String? password, + String? passcode, }) => Home( name: name ?? this.name, uri: uri ?? this.uri, username: username ?? this.username, - password: this.password, + password: password ?? this.password, + passcode: passcode ?? this.passcode, ); @override @@ -61,11 +67,12 @@ class Home { return name == other.name && uri == other.uri && username == other.username && - password == other.password; + password == other.password && + passcode == other.passcode; } @override - int get hashCode => Object.hash(name, uri, username, password); + int get hashCode => Object.hash(name, uri, username, password, passcode); @override String toString() { diff --git a/lib/states/home_add_state.dart b/lib/states/home_add_state.dart index 3d28aaa..6809664 100644 --- a/lib/states/home_add_state.dart +++ b/lib/states/home_add_state.dart @@ -9,6 +9,7 @@ class HomeAddInitialState extends HomeAddState { final String password; final String channel; final String sign; + final String passcode; HomeAddInitialState({ required this.name, @@ -17,6 +18,7 @@ class HomeAddInitialState extends HomeAddState { required this.password, required this.channel, required this.sign, + required this.passcode, }); } @@ -32,20 +34,15 @@ class HomeAddFormErrorState extends HomeAddState { final String? nameError; final String? serverError; final String? channelError; - final String? signError; bool get hasError { - return nameError != null || - serverError != null || - channelError != null || - signError != null; + return nameError != null || serverError != null || channelError != null; } HomeAddFormErrorState({ this.nameError, this.serverError, this.channelError, - this.signError, }); } @@ -109,6 +106,7 @@ class HomeAddSubmit extends HomeAddEvent { final String password; final String channel; final String sign; + final String passcode; HomeAddSubmit({ required this.name, @@ -117,6 +115,7 @@ class HomeAddSubmit extends HomeAddEvent { required this.password, required this.channel, required this.sign, + required this.passcode, this.home, }); } diff --git a/lib/states/home_state.dart b/lib/states/home_state.dart index 44047fb..bcdc499 100644 --- a/lib/states/home_state.dart +++ b/lib/states/home_state.dart @@ -21,3 +21,5 @@ class HomeSelected extends HomeEvent { } class HomeRefresh extends HomeEvent {} + +class HomeUnlock extends HomeEvent {} diff --git a/lib/views/call_view.dart b/lib/views/call_view.dart index fc99cf9..cf79520 100644 --- a/lib/views/call_view.dart +++ b/lib/views/call_view.dart @@ -1,7 +1,9 @@ import 'package:dieklingel_app/blocs/call_view_bloc.dart'; +import 'package:dieklingel_app/blocs/home_view_bloc.dart'; import 'package:dieklingel_app/components/icon_builder.dart'; import 'package:dieklingel_app/components/map_builder.dart'; import 'package:dieklingel_app/states/call_state.dart'; +import 'package:dieklingel_app/states/home_state.dart'; import 'package:dieklingel_app/utils/microphone_state.dart'; import 'package:dieklingel_app/utils/speaker_state.dart'; import 'package:flutter/cupertino.dart'; @@ -127,13 +129,32 @@ class _Toolbar extends StatelessWidget { } : null, ), - const _ToolbarButton( - icon: Icon( + _ToolbarButton( + icon: const Icon( CupertinoIcons.lock_fill, color: Colors.white, size: 30, ), color: Colors.amber, + onPressed: () { + context.read().add(HomeUnlock()); + showCupertinoDialog( + context: context, + builder: (BuildContext context) { + Future.delayed(const Duration(milliseconds: 600), () { + Navigator.of(context).pop(); + }); + + return Center( + child: Icon( + CupertinoIcons.lock_open_fill, + size: 150, + color: Colors.green.shade400, + ), + ); + }, + ); + }, ), ]; } diff --git a/lib/views/home_add_view.dart b/lib/views/home_add_view.dart index 3a75a26..04cea56 100644 --- a/lib/views/home_add_view.dart +++ b/lib/views/home_add_view.dart @@ -29,6 +29,7 @@ class _HomeAddView extends State { : path.normalize("./${widget.home!.uri.path}"), ); late final _sign = TextEditingController(text: widget.home?.uri.fragment); + late final _passcode = TextEditingController(text: widget.home?.passcode); @override Widget build(BuildContext context) { @@ -81,6 +82,7 @@ class _HomeAddView extends State { password: _password.text, channel: _channel.text, sign: _sign.text, + passcode: _passcode.text, ), ); }, @@ -140,13 +142,18 @@ class _HomeAddView extends State { ), CupertinoTextFormFieldRow( prefix: const Text("Sign"), - validator: (value) => state is HomeAddFormErrorState - ? state.signError - : null, - autovalidateMode: AutovalidateMode.always, controller: _sign, ) ], + ), + CupertinoFormSection.insetGrouped( + header: const Text("Doorunit"), + children: [ + CupertinoTextFormFieldRow( + prefix: const Text("Passcode"), + controller: _passcode, + ), + ], ) ], ), From 2db62a3712621e60a2e7eea3858b6c17d52a2f03 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Thu, 24 Aug 2023 20:32:51 +0200 Subject: [PATCH 02/35] fix: hangup connection --- lib/blocs/call_view_bloc.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/blocs/call_view_bloc.dart b/lib/blocs/call_view_bloc.dart index fcc37d9..f837911 100644 --- a/lib/blocs/call_view_bloc.dart +++ b/lib/blocs/call_view_bloc.dart @@ -57,6 +57,7 @@ class CallViewBloc extends Bloc { username: home.username ?? "", password: home.password ?? "", ); + this.client = client; } on mqtt.NoConnectionException catch (exception) { emit(CallCancelState(exception.toString())); return; @@ -197,11 +198,9 @@ class CallViewBloc extends Bloc { await rtcclient?.dispose(); rtcclient = null; - client?.publish( + await client?.publish( path.normalize("./${home.uri.path}/rtc/connections/close/$uuid"), - jsonEncode( - Request("GET", ""), - ), + Request("GET", "").toJsonString(), ); candidateSub?.cancel(); candidateSub = null; From 687740bac0b9c65c423c113f190d53e0142e1895 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Thu, 24 Aug 2023 20:35:19 +0200 Subject: [PATCH 03/35] release v.1.2.3+1 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index c2cf443..817f0ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.2.2+1 +version: 1.2.3+1 environment: sdk: ">=2.18.6 <3.7.11" From d6afe53d193626b9bece9c6c3cfd4324b324d363 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Fri, 25 Aug 2023 10:47:05 +0200 Subject: [PATCH 04/35] fix: catch MediaStream open exception --- lib/utils/media_ressource.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/utils/media_ressource.dart b/lib/utils/media_ressource.dart index 19b4fd0..8b3ccfd 100644 --- a/lib/utils/media_ressource.dart +++ b/lib/utils/media_ressource.dart @@ -13,7 +13,11 @@ class MediaRessource { 'audio': audio, 'video': video, }; - _stream = await navigator.mediaDevices.getUserMedia(constraints); + try { + _stream = await navigator.mediaDevices.getUserMedia(constraints); + } catch (e) { + // TODO: noting stream is empty + } return _stream; } From 82e14b0ed0ea5cc7d43577615b90e816e8cbc973 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Fri, 25 Aug 2023 12:06:26 +0200 Subject: [PATCH 05/35] feat: obscure unlock text --- lib/views/home_add_view.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/views/home_add_view.dart b/lib/views/home_add_view.dart index 04cea56..f0c59c4 100644 --- a/lib/views/home_add_view.dart +++ b/lib/views/home_add_view.dart @@ -151,6 +151,7 @@ class _HomeAddView extends State { children: [ CupertinoTextFormFieldRow( prefix: const Text("Passcode"), + obscureText: true, controller: _passcode, ), ], From 67e0c97dba0d2e6c3efc90759c4e1362baff090c Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Sun, 5 Nov 2023 11:18:15 +0100 Subject: [PATCH 06/35] feat: use view model for home view --- lib/blocs/home_view_bloc.dart | 73 ---------- lib/main.dart | 10 +- lib/ui/view_models/home_view_model.dart | 39 ++++++ lib/ui/views/home_view.dart | 179 ++++++++++++++++++++++++ lib/views/call_view.dart | 4 +- lib/views/home_view.dart | 163 --------------------- pubspec.lock | 2 +- pubspec.yaml | 1 + 8 files changed, 226 insertions(+), 245 deletions(-) delete mode 100644 lib/blocs/home_view_bloc.dart create mode 100644 lib/ui/view_models/home_view_model.dart create mode 100644 lib/ui/views/home_view.dart delete mode 100644 lib/views/home_view.dart diff --git a/lib/blocs/home_view_bloc.dart b/lib/blocs/home_view_bloc.dart deleted file mode 100644 index ae08ef3..0000000 --- a/lib/blocs/home_view_bloc.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'dart:async'; - -import 'package:dieklingel_app/models/hive_home.dart'; -import 'package:dieklingel_app/models/request.dart'; -import 'package:dieklingel_app/repositories/home_repository.dart'; -import 'package:dieklingel_app/states/home_state.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:mqtt/mqtt.dart'; -import 'package:path/path.dart' as path; - -class HomeViewBloc extends Bloc { - final HomeRepository homeRepository; - - HomeViewBloc(this.homeRepository) : super(HomeState()) { - on(_onSelected); - on(_onRefresh); - on(_onUnlock); - - add(HomeRefresh()); - } - - Future _onSelected(HomeSelected event, Emitter emit) async { - await homeRepository.select(event.home); - emit( - HomeSelectedState(home: event.home, homes: homeRepository.homes), - ); - } - - Future _onRefresh(HomeRefresh event, Emitter emit) async { - HiveHome? selected = homeRepository.selected; - if (selected == null && homeRepository.homes.isNotEmpty) { - await homeRepository.select(homeRepository.homes.first); - selected = homeRepository.selected; - } - - if (selected == null) { - emit(HomeState( - homes: homeRepository.homes, - )); - } else { - emit( - HomeSelectedState(home: selected, homes: homeRepository.homes), - ); - } - } - - Future _onUnlock(HomeUnlock event, Emitter emit) async { - HiveHome? home = homeRepository.selected; - if (home == null) { - return; - } - - MqttClient client = MqttClient(home.uri); - try { - await client.connect( - username: home.username ?? "", - password: home.password ?? "", - ); - } catch (e) { - print("error ${e.toString()}"); - return; - } - - await client.publish( - path.normalize("./${home.uri.path}/actions/execute"), - Request.withJsonBody("GET", { - "pattern": "unlock", - "environment": { - "PASSCODE": home.passcode, - } - }).toJsonString()); - } -} diff --git a/lib/main.dart b/lib/main.dart index dc9dcf1..4456a5a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,16 @@ import 'package:dieklingel_app/blocs/call_view_bloc.dart'; import 'package:dieklingel_app/blocs/home_add_view_bloc.dart'; -import 'package:dieklingel_app/blocs/home_view_bloc.dart'; +import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; import 'package:dieklingel_app/handlers/notification_handler.dart'; import 'package:dieklingel_app/models/device.dart'; import 'package:dieklingel_app/models/request.dart'; import 'package:dieklingel_app/repositories/home_repository.dart'; import 'package:dieklingel_app/repositories/ice_server_repository.dart'; -import 'package:dieklingel_app/views/home_view.dart'; +import 'package:dieklingel_app/ui/views/home_view.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:mqtt/mqtt.dart'; import 'package:path/path.dart' as path; +import 'package:provider/provider.dart'; import './models/home.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -55,7 +56,6 @@ void main() async { ], child: MultiBlocProvider( providers: [ - BlocProvider(create: (_) => HomeViewBloc(homeRepository)), BlocProvider(create: (_) => HomeAddViewBloc(homeRepository)), BlocProvider( create: (_) => IceServerAddViewBloc(iceServerRepository)), @@ -139,8 +139,8 @@ class _App extends State { @override Widget build(BuildContext context) { return CupertinoApp( - home: BlocProvider( - create: (_) => HomeViewBloc(context.read()), + home: ChangeNotifierProvider( + create: (_) => HomeViewModel(context.read()), child: const HomeView(), ), ); diff --git a/lib/ui/view_models/home_view_model.dart b/lib/ui/view_models/home_view_model.dart new file mode 100644 index 0000000..f270013 --- /dev/null +++ b/lib/ui/view_models/home_view_model.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:dieklingel_app/models/hive_home.dart'; +import 'package:dieklingel_app/repositories/home_repository.dart'; +import 'package:flutter/cupertino.dart'; + +class HomeViewModel extends ChangeNotifier { + final HomeRepository homeRepository; + + HomeViewModel(this.homeRepository); + + HiveHome? get home { + return homeRepository.selected; + } + + set home(HiveHome? home) { + (() async { + await homeRepository.select(home); + notifyListeners(); + })(); + } + + List get homes { + return homeRepository.homes; + } + + Future refresh() async { + HiveHome? selected = homeRepository.selected; + if (selected == null && homeRepository.homes.isNotEmpty) { + await homeRepository.select(homeRepository.homes.first); + selected = homeRepository.selected; + } + if (selected == home) { + return; + } + + notifyListeners(); + } +} diff --git a/lib/ui/views/home_view.dart b/lib/ui/views/home_view.dart new file mode 100644 index 0000000..eb3dcae --- /dev/null +++ b/lib/ui/views/home_view.dart @@ -0,0 +1,179 @@ +import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; +import 'package:dieklingel_app/states/call_state.dart'; +import 'package:dieklingel_app/views/call_view.dart'; +import 'package:dieklingel_app/views/home_add_view.dart'; +import 'package:dieklingel_app/views/ice_server_add_view.dart'; +import 'package:dieklingel_app/views/settings_view.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pull_down_button/pull_down_button.dart'; + +import '../../blocs/call_view_bloc.dart'; +import '../../models/hive_home.dart'; + +class HomeView extends StatelessWidget { + const HomeView({super.key}); + + void _onAddHome(BuildContext context) async { + final homeViewModel = context.read(); + await showCupertinoModalPopup( + context: context, + builder: (context) { + return const CupertinoPopupSurface( + child: HomeAddView(), + ); + }, + ); + await homeViewModel.refresh(); + } + + void _onAddIceServer(BuildContext context) async { + final homeViewModel = context.read(); + await showCupertinoModalPopup( + context: context, + builder: (context) { + return const CupertinoPopupSurface( + child: IceServerAddView(), + ); + }, + ); + homeViewModel.refresh(); + } + + void _onSettingsTap(BuildContext context) async { + final homeViewModel = context.read(); + await Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) => const SettingsView(), + ), + ); + await homeViewModel.refresh(); + } + + @override + Widget build(BuildContext context) { + final title = + context.select((value) => value.home)?.name ?? + "Home"; + final homes = + context.select>((value) => value.homes); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _AppBarAddButton( + addHomeFunc: _onAddHome, + addIceServerFunc: _onAddIceServer, + ), + PullDownButton( + itemBuilder: (context) => [ + PullDownMenuItem( + onTap: () => _onSettingsTap(context), + title: "Settings", + icon: CupertinoIcons.settings, + ), + if (homes.isNotEmpty) ...[ + const PullDownMenuDivider.large(), + ], + for (HiveHome home in homes) ...[ + PullDownMenuItem.selectable( + selected: context.select( + (value) => value.home) == + home, + onTap: () { + context.read().home = home; + context.read().add(CallHangup()); + }, + title: home.name, + ), + if (home != homes.last) ...[const PullDownMenuDivider()], + ] + ], + buttonBuilder: (context, showMenu) => CupertinoButton( + padding: EdgeInsets.zero, + onPressed: showMenu, + child: const Icon(CupertinoIcons.ellipsis_circle), + ), + ), + ], + ), + ), + child: _Content(), + ); + } +} + +class _Content extends StatelessWidget { + void _onAddHome(BuildContext context) async { + final homeViewModel = context.read(); + await showCupertinoModalPopup( + context: context, + builder: (context) { + return const CupertinoPopupSurface( + child: HomeAddView(), + ); + }, + ); + homeViewModel.refresh(); + } + + @override + Widget build(BuildContext context) { + HiveHome? home = + context.select((value) => value.home); + + if (home == null) { + return Center( + child: CupertinoButton( + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.add), + Text("add your first Home"), + ], + ), + onPressed: () => _onAddHome(context), + ), + ); + } + + return const CallView(); + } +} + +class _AppBarAddButton extends StatelessWidget { + final Function(BuildContext) addHomeFunc; + final Function(BuildContext) addIceServerFunc; + + const _AppBarAddButton({ + required this.addHomeFunc, + required this.addIceServerFunc, + }); + + @override + Widget build(BuildContext context) { + return PullDownButton( + itemBuilder: (context) => [ + PullDownMenuItem( + onTap: () => addHomeFunc(context), + title: "add Home", + icon: CupertinoIcons.home, + ), + const PullDownMenuDivider(), + PullDownMenuItem( + onTap: () => addIceServerFunc(context), + title: "add ICE Server", + icon: CupertinoIcons.cloud, + ) + ], + buttonBuilder: (context, showMenu) => CupertinoButton( + padding: EdgeInsets.zero, + onPressed: showMenu, + child: const Icon(CupertinoIcons.plus), + ), + ); + } +} diff --git a/lib/views/call_view.dart b/lib/views/call_view.dart index cf79520..6e40a4f 100644 --- a/lib/views/call_view.dart +++ b/lib/views/call_view.dart @@ -1,9 +1,7 @@ import 'package:dieklingel_app/blocs/call_view_bloc.dart'; -import 'package:dieklingel_app/blocs/home_view_bloc.dart'; import 'package:dieklingel_app/components/icon_builder.dart'; import 'package:dieklingel_app/components/map_builder.dart'; import 'package:dieklingel_app/states/call_state.dart'; -import 'package:dieklingel_app/states/home_state.dart'; import 'package:dieklingel_app/utils/microphone_state.dart'; import 'package:dieklingel_app/utils/speaker_state.dart'; import 'package:flutter/cupertino.dart'; @@ -137,7 +135,7 @@ class _Toolbar extends StatelessWidget { ), color: Colors.amber, onPressed: () { - context.read().add(HomeUnlock()); + //TODO: context.read().add(HomeUnlock()); showCupertinoDialog( context: context, builder: (BuildContext context) { diff --git a/lib/views/home_view.dart b/lib/views/home_view.dart deleted file mode 100644 index 43dc9ce..0000000 --- a/lib/views/home_view.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:dieklingel_app/blocs/home_view_bloc.dart'; -import 'package:dieklingel_app/states/call_state.dart'; -import 'package:dieklingel_app/states/home_state.dart'; -import 'package:dieklingel_app/views/call_view.dart'; -import 'package:dieklingel_app/views/home_add_view.dart'; -import 'package:dieklingel_app/views/ice_server_add_view.dart'; -import 'package:dieklingel_app/views/settings_view.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:pull_down_button/pull_down_button.dart'; - -import '../blocs/call_view_bloc.dart'; -import '../models/hive_home.dart'; - -class HomeView extends StatelessWidget { - const HomeView({super.key}); - - void _onAddHome(BuildContext context) async { - final bloc = context.read(); - await showCupertinoModalPopup( - context: context, - builder: (context) { - return const CupertinoPopupSurface( - child: HomeAddView(), - ); - }, - ); - bloc.add(HomeRefresh()); - } - - void _onAddIceServer(BuildContext context) async { - final bloc = context.read(); - await showCupertinoModalPopup( - context: context, - builder: (context) { - return const CupertinoPopupSurface( - child: IceServerAddView(), - ); - }, - ); - bloc.add(HomeRefresh()); - } - - void _onSettingsTap(BuildContext context) async { - final bloc = context.read(); - await Navigator.of(context).push( - CupertinoPageRoute( - builder: (context) => const SettingsView(), - ), - ); - bloc.add(HomeRefresh()); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: Text(state is HomeSelectedState ? state.home.name : "Home"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PullDownButton( - itemBuilder: (context) => [ - PullDownMenuItem( - onTap: () => _onAddHome(context), - title: "add Home", - icon: CupertinoIcons.home, - ), - const PullDownMenuDivider(), - PullDownMenuItem( - onTap: () => _onAddIceServer(context), - title: "add ICE Server", - icon: CupertinoIcons.cloud, - ) - ], - buttonBuilder: (context, showMenu) => CupertinoButton( - padding: EdgeInsets.zero, - onPressed: showMenu, - child: const Icon(CupertinoIcons.plus), - ), - ), - PullDownButton( - itemBuilder: (context) => [ - PullDownMenuItem( - onTap: () => _onSettingsTap(context), - title: "Settings", - icon: CupertinoIcons.settings, - ), - if (state.homes.isNotEmpty) ...[ - const PullDownMenuDivider.large(), - ], - for (HiveHome home in state.homes) ...[ - PullDownMenuItem.selectable( - selected: - state is HomeSelectedState && home == state.home, - onTap: () { - context - .read() - .add(HomeSelected(home: home)); - context.read().add(CallHangup()); - }, - title: home.name, - ), - if (home != state.homes.last) ...[ - const PullDownMenuDivider() - ], - ] - ], - buttonBuilder: (context, showMenu) => CupertinoButton( - padding: EdgeInsets.zero, - onPressed: showMenu, - child: const Icon(CupertinoIcons.ellipsis_circle), - ), - ), - ], - ), - ), - child: _Content(), - ); - }, - ); - } -} - -class _Content extends StatelessWidget { - void _onAddHome(BuildContext context) async { - final bloc = context.read(); - await showCupertinoModalPopup( - context: context, - builder: (context) { - return const CupertinoPopupSurface( - child: HomeAddView(), - ); - }, - ); - bloc.add(HomeRefresh()); - } - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: ((context, state) { - if (state is! HomeSelectedState) { - return Center( - child: CupertinoButton( - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.add), - Text("add your first Home"), - ], - ), - onPressed: () => _onAddHome(context), - ), - ); - } - return const CallView(); - }), - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index aab356e..57c9392 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -552,7 +552,7 @@ packages: source: hosted version: "4.2.4" provider: - dependency: transitive + dependency: "direct main" description: name: provider sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f diff --git a/pubspec.yaml b/pubspec.yaml index 817f0ba..f3a49d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: async: ^2.11.0 path: ^1.8.3 mqtt_client: ^10.0.0 + provider: ^6.0.5 dev_dependencies: flutter_test: From cb3a120cc78c80aecf1a9f8c5771b7f5055d4a4a Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Sun, 5 Nov 2023 21:23:13 +0100 Subject: [PATCH 07/35] feat: use mvvm for call view --- lib/blocs/home_add_view_bloc.dart | 6 +- lib/handlers/call_handler.dart | 19 -- lib/models/messages/answer_message.dart | 37 +++ lib/models/messages/candidate_message.dart | 36 +++ .../messages/candidate_message_body.dart | 36 +++ lib/models/messages/close_message.dart | 31 +++ lib/models/messages/message_header.dart | 34 +++ lib/models/messages/offer_message.dart | 37 +++ lib/models/messages/session_message_body.dart | 36 +++ .../messages/session_message_header.dart | 39 +++ lib/ui/view_models/call_view_model.dart | 182 ++++++++++++++ lib/ui/view_models/core_view_model.dart | 44 ++++ lib/ui/view_models/home_view_model.dart | 9 - lib/ui/views/call_view.dart | 215 ++++++++++++++++ lib/ui/views/core_view.dart | 50 ++++ lib/ui/views/home_view.dart | 20 +- lib/views/call_view.dart | 230 ------------------ pubspec.lock | 8 + pubspec.yaml | 1 + 19 files changed, 802 insertions(+), 268 deletions(-) delete mode 100644 lib/handlers/call_handler.dart create mode 100644 lib/models/messages/answer_message.dart create mode 100644 lib/models/messages/candidate_message.dart create mode 100644 lib/models/messages/candidate_message_body.dart create mode 100644 lib/models/messages/close_message.dart create mode 100644 lib/models/messages/message_header.dart create mode 100644 lib/models/messages/offer_message.dart create mode 100644 lib/models/messages/session_message_body.dart create mode 100644 lib/models/messages/session_message_header.dart create mode 100644 lib/ui/view_models/call_view_model.dart create mode 100644 lib/ui/view_models/core_view_model.dart create mode 100644 lib/ui/views/call_view.dart create mode 100644 lib/ui/views/core_view.dart delete mode 100644 lib/views/call_view.dart diff --git a/lib/blocs/home_add_view_bloc.dart b/lib/blocs/home_add_view_bloc.dart index bae2bde..fad44e5 100644 --- a/lib/blocs/home_add_view_bloc.dart +++ b/lib/blocs/home_add_view_bloc.dart @@ -25,13 +25,13 @@ class HomeAddViewBloc extends Bloc { } String? serverError; - RegExp serverRegex = RegExp( - r'^(mqtt|mqtts|ws|wss):\/\/(?:[A-Za-z0-9]+\.)+[A-Za-z0-9]{2,3}:\d{1,5}(\/?)$', + /*RegExp serverRegex = RegExp( + r'^(mqtt|mqtts|ws|wss):\/\/(?:[A-Za-z0-9]+\.)+[A-Za-z0-9]+:\d{1,5}(\/?)$', ); if (!serverRegex.hasMatch(event.server)) { serverError = "Please enter a server url within the format 'mqtt://server.org:1883/'"; - } + }*/ String? channelError; RegExp channelRegex = RegExp( diff --git a/lib/handlers/call_handler.dart b/lib/handlers/call_handler.dart deleted file mode 100644 index 68881b4..0000000 --- a/lib/handlers/call_handler.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:shelf/shelf.dart'; -import 'package:shelf_router/shelf_router.dart'; - -class CallHandler { - final Uri uri; - final String uuid; - final String? username; - final String? password; - final Router handler = Router(); - - CallHandler( - this.uri, { - required this.uuid, - this.username, - this.password, - }) { - handler.connect("/rtc/connections/$uuid", (Request request) {}); - } -} diff --git a/lib/models/messages/answer_message.dart b/lib/models/messages/answer_message.dart new file mode 100644 index 0000000..74a2f1c --- /dev/null +++ b/lib/models/messages/answer_message.dart @@ -0,0 +1,37 @@ +import 'package:blueprint/blueprint.dart'; +import 'package:dieklingel_app/models/messages/session_message_header.dart'; + +import 'session_message_body.dart'; + +class AnswerMessage { + final SessionMessageHeader header; + final SessionMessageBody body; + + AnswerMessage({ + required this.header, + required this.body, + }); + + factory AnswerMessage.fromMap(dynamic map) { + matchMap( + map, + { + "header": MapF, + "body": MapF, + }, + throwable: true, + ); + + return AnswerMessage( + header: SessionMessageHeader.fromMap(map["header"]), + body: SessionMessageBody.fromMap(map["body"]), + ); + } + + Map toMap() { + return { + "header": header.toMap(), + "body": body.toMap(), + }; + } +} diff --git a/lib/models/messages/candidate_message.dart b/lib/models/messages/candidate_message.dart new file mode 100644 index 0000000..525aec4 --- /dev/null +++ b/lib/models/messages/candidate_message.dart @@ -0,0 +1,36 @@ +import 'package:blueprint/blueprint.dart'; +import 'package:dieklingel_app/models/messages/candidate_message_body.dart'; +import 'package:dieklingel_app/models/messages/session_message_header.dart'; + +class CandidateMessage { + final SessionMessageHeader header; + final CandidateMessageBody body; + + CandidateMessage({ + required this.header, + required this.body, + }); + + factory CandidateMessage.fromMap(dynamic map) { + matchMap( + map, + { + "header": MapF, + "body": MapF, + }, + throwable: true, + ); + + return CandidateMessage( + header: SessionMessageHeader.fromMap(map["header"]), + body: CandidateMessageBody.fromMap(map["body"]), + ); + } + + Map toMap() { + return { + "header": header.toMap(), + "body": body.toMap(), + }; + } +} diff --git a/lib/models/messages/candidate_message_body.dart b/lib/models/messages/candidate_message_body.dart new file mode 100644 index 0000000..c97eb1a --- /dev/null +++ b/lib/models/messages/candidate_message_body.dart @@ -0,0 +1,36 @@ +import 'package:blueprint/blueprint.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +class CandidateMessageBody { + final RTCIceCandidate iceCandidate; + + CandidateMessageBody({required this.iceCandidate}); + + factory CandidateMessageBody.fromMap(dynamic map) { + matchMap( + map, + { + "iceCandidate": MapF.of({ + "candidate": StringF, + "sdpMid": StringF, + "sdpMLineIndex": IntF, + }), + }, + throwable: true, + ); + + return CandidateMessageBody( + iceCandidate: RTCIceCandidate( + map["iceCandidate"]["candidate"], + map["iceCandidate"]["sdpMid"], + map["iceCandidate"]["sdpMLineIndex"], + ), + ); + } + + Map toMap() { + return { + "iceCandidate": iceCandidate.toMap(), + }; + } +} diff --git a/lib/models/messages/close_message.dart b/lib/models/messages/close_message.dart new file mode 100644 index 0000000..1785104 --- /dev/null +++ b/lib/models/messages/close_message.dart @@ -0,0 +1,31 @@ +import 'package:blueprint/blueprint.dart'; + +import 'session_message_header.dart'; + +class CloseMessage { + final SessionMessageHeader header; + + CloseMessage({ + required this.header, + }); + + factory CloseMessage.fromMap(dynamic map) { + matchMap( + map, + { + "header": MapF, + }, + throwable: true, + ); + + return CloseMessage( + header: SessionMessageHeader.fromMap(map["header"]), + ); + } + + Map toMap() { + return { + "header": header.toMap(), + }; + } +} diff --git a/lib/models/messages/message_header.dart b/lib/models/messages/message_header.dart new file mode 100644 index 0000000..e221e13 --- /dev/null +++ b/lib/models/messages/message_header.dart @@ -0,0 +1,34 @@ +import 'package:blueprint/blueprint.dart'; + +class MessageHeader { + final String senderDeviceId; + final String sessionId; + + MessageHeader({ + required this.senderDeviceId, + required this.sessionId, + }); + + factory MessageHeader.fromMap(dynamic map) { + matchMap( + map, + { + "senderDeviceId": StringF, + "sessionId": StringF, + }, + throwable: true, + ); + + return MessageHeader( + senderDeviceId: map["senderDeviceId"], + sessionId: map["sessionId"], + ); + } + + Map toMap() { + return { + "senderDeviceId": senderDeviceId, + "sessionId": sessionId, + }; + } +} diff --git a/lib/models/messages/offer_message.dart b/lib/models/messages/offer_message.dart new file mode 100644 index 0000000..a2d7353 --- /dev/null +++ b/lib/models/messages/offer_message.dart @@ -0,0 +1,37 @@ +import 'package:blueprint/blueprint.dart'; + +import 'message_header.dart'; +import 'session_message_body.dart'; + +class OfferMessage { + final MessageHeader header; + final SessionMessageBody body; + + OfferMessage({ + required this.header, + required this.body, + }); + + factory OfferMessage.fromMap(dynamic map) { + matchMap( + map, + { + "header": MapF, + "body": MapF, + }, + throwable: true, + ); + + return OfferMessage( + header: MessageHeader.fromMap(map["header"]), + body: SessionMessageBody.fromMap(map["body"]), + ); + } + + Map toMap() { + return { + "header": header.toMap(), + "body": body.toMap(), + }; + } +} diff --git a/lib/models/messages/session_message_body.dart b/lib/models/messages/session_message_body.dart new file mode 100644 index 0000000..3d07958 --- /dev/null +++ b/lib/models/messages/session_message_body.dart @@ -0,0 +1,36 @@ +import 'package:blueprint/blueprint.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +class SessionMessageBody { + final RTCSessionDescription sessionDescription; + + SessionMessageBody({ + required this.sessionDescription, + }); + + factory SessionMessageBody.fromMap(Map map) { + matchMap( + map, + { + "sessionDescription": MapF.of({ + "type": StringF, + "sdp": StringF, + }) + }, + throwable: true, + ); + + return SessionMessageBody( + sessionDescription: RTCSessionDescription( + map["sessionDescription"]["sdp"], + map["sessionDescription"]["type"], + ), + ); + } + + Map toMap() { + return { + "sessionDescription": sessionDescription.toMap(), + }; + } +} diff --git a/lib/models/messages/session_message_header.dart b/lib/models/messages/session_message_header.dart new file mode 100644 index 0000000..903c236 --- /dev/null +++ b/lib/models/messages/session_message_header.dart @@ -0,0 +1,39 @@ +import 'package:blueprint/blueprint.dart'; + +class SessionMessageHeader { + final String senderDeviceId; + final String sessionId; + final String senderSessionId; + + SessionMessageHeader({ + required this.senderDeviceId, + required this.sessionId, + required this.senderSessionId, + }); + + factory SessionMessageHeader.fromMap(dynamic map) { + matchMap( + map, + { + "senderDeviceId": StringF, + "sessionId": StringF, + "senderSessionId": StringF, + }, + throwable: true, + ); + + return SessionMessageHeader( + senderDeviceId: map["senderDeviceId"], + sessionId: map["sessionId"], + senderSessionId: map["sessionId"], + ); + } + + Map toMap() { + return { + "senderDeviceId": senderDeviceId, + "sessionId": sessionId, + "senderSessionId": senderSessionId, + }; + } +} diff --git a/lib/ui/view_models/call_view_model.dart b/lib/ui/view_models/call_view_model.dart new file mode 100644 index 0000000..c924577 --- /dev/null +++ b/lib/ui/view_models/call_view_model.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; + +import 'package:dieklingel_app/models/hive_home.dart'; +import 'package:dieklingel_app/models/messages/answer_message.dart'; +import 'package:dieklingel_app/models/messages/candidate_message.dart'; +import 'package:dieklingel_app/models/messages/candidate_message_body.dart'; +import 'package:dieklingel_app/models/messages/close_message.dart'; +import 'package:dieklingel_app/models/messages/message_header.dart'; +import 'package:dieklingel_app/models/messages/offer_message.dart'; +import 'package:dieklingel_app/models/messages/session_message_body.dart'; +import 'package:dieklingel_app/models/messages/session_message_header.dart'; +import 'package:dieklingel_app/repositories/ice_server_repository.dart'; +import 'package:dieklingel_app/utils/rtc_client_wrapper.dart'; +import 'package:dieklingel_app/utils/rtc_transceiver.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:mqtt/mqtt.dart'; +import 'package:path/path.dart'; +import 'package:uuid/uuid.dart'; + +class CallViewModel extends ChangeNotifier { + final HiveHome home; + final MqttClient client; + final IceServerRepository iceServerRepository; + + RtcClientWrapper? _call; + String _remoteSessionId = ""; + String _localSessionId = ""; + + CallViewModel(this.home, this.client, this.iceServerRepository) { + client.subscribe( + "${home.username}/connections/answer", + (topic, message) async { + final call = _call; + if (call == null) { + return; + } + + try { + final payload = AnswerMessage.fromMap(json.decode(message)); + _remoteSessionId = payload.header.senderSessionId; + await call.setRemoteDescription(payload.body.sessionDescription); + } catch (e) { + print(e); + } + }, + ); + + client.subscribe( + "${home.username}/connections/candidate", + (topic, message) { + final call = _call; + if (call == null) { + return; + } + + try { + final payload = CandidateMessage.fromMap(json.decode(message)); + call.addIceCandidate(payload.body.iceCandidate); + } catch (e) { + print(e); + } + }, + ); + + client.subscribe( + "${home.username}/connections/close", + (topic, message) async { + final call = _call; + if (call == null) { + return; + } + + try { + CloseMessage.fromMap(json.decode(message)); + await call.dispose(); + _call = null; + } catch (e) { + print(e); + } + }, + ); + } + + bool get isInCall { + return _call != null; + } + + bool get isConnecting { + final call = _call; + if (call == null) { + return false; + } + return call.state.value != + RTCPeerConnectionState.RTCPeerConnectionStateConnected; + } + + RTCVideoRenderer? get renderer { + return _call?.renderer; + } + + void dial() async { + _localSessionId = const Uuid().v4(); + final call = await RtcClientWrapper.create( + uuid: _localSessionId, + iceServers: iceServerRepository.servers, + transceivers: [ + RtcTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, + direction: TransceiverDirection.RecvOnly, + ), + RtcTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeAudio, + direction: TransceiverDirection.SendRecv, + ), + ], + ); + call.state.addListener(() { + print(call.state.value); + notifyListeners(); + }); + _call = call; + notifyListeners(); + + final offer = await call.offer(); + final payload = OfferMessage( + header: MessageHeader( + senderDeviceId: home.username ?? "", + sessionId: call.uuid, + ), + body: SessionMessageBody( + sessionDescription: offer, + ), + ); + + client.publish( + normalize("./${home.uri.path}/connections/offer"), + json.encode(payload.toMap()), + ); + + call.onIceCandidate((p0) { + final payload = CandidateMessage( + header: SessionMessageHeader( + senderDeviceId: home.username ?? "", + senderSessionId: _localSessionId, + sessionId: _remoteSessionId, + ), + body: CandidateMessageBody( + iceCandidate: p0, + ), + ); + + client.publish( + normalize("./${home.uri.path}/connections/candidate"), + json.encode(payload.toMap()), + ); + }); + } + + void hangup() async { + final call = _call; + if (call == null) { + return; + } + final payload = CloseMessage( + header: SessionMessageHeader( + senderDeviceId: home.username ?? "", + senderSessionId: call.uuid, + sessionId: _remoteSessionId, + ), + ); + + await call.dispose(); + _call = null; + notifyListeners(); + + client.publish( + normalize("./${home.uri.path}/connections/close"), + json.encode(payload.toMap()), + ); + } +} diff --git a/lib/ui/view_models/core_view_model.dart b/lib/ui/view_models/core_view_model.dart new file mode 100644 index 0000000..4840e79 --- /dev/null +++ b/lib/ui/view_models/core_view_model.dart @@ -0,0 +1,44 @@ +import 'package:dieklingel_app/models/hive_home.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:mqtt/mqtt.dart'; +import 'package:mqtt_client/mqtt_client.dart' as mqtt; + +class CoreViewModel extends ChangeNotifier { + final HiveHome home; + final MqttClient client; + + bool _isConnected = false; + String? _connectionErrorMessage; + + CoreViewModel(this.home, this.client) { + connect(); + } + + bool get isConnected { + return _isConnected; + } + + String? get connectionErrorMessage { + return _connectionErrorMessage; + } + + void connect() async { + _isConnected = false; + _connectionErrorMessage = null; + notifyListeners(); + + try { + await client.connect( + username: home.username ?? "", + password: home.password ?? "", + ); + } on mqtt.NoConnectionException catch (exception) { + _connectionErrorMessage = exception.toString(); + notifyListeners(); + return; + } + + _isConnected = true; + notifyListeners(); + } +} diff --git a/lib/ui/view_models/home_view_model.dart b/lib/ui/view_models/home_view_model.dart index f270013..9ecf24a 100644 --- a/lib/ui/view_models/home_view_model.dart +++ b/lib/ui/view_models/home_view_model.dart @@ -25,15 +25,6 @@ class HomeViewModel extends ChangeNotifier { } Future refresh() async { - HiveHome? selected = homeRepository.selected; - if (selected == null && homeRepository.homes.isNotEmpty) { - await homeRepository.select(homeRepository.homes.first); - selected = homeRepository.selected; - } - if (selected == home) { - return; - } - notifyListeners(); } } diff --git a/lib/ui/views/call_view.dart b/lib/ui/views/call_view.dart new file mode 100644 index 0000000..e43f1dc --- /dev/null +++ b/lib/ui/views/call_view.dart @@ -0,0 +1,215 @@ +import 'package:dieklingel_app/blocs/call_view_bloc.dart'; +import 'package:dieklingel_app/components/icon_builder.dart'; +import 'package:dieklingel_app/components/map_builder.dart'; +import 'package:dieklingel_app/states/call_state.dart'; +import 'package:dieklingel_app/ui/view_models/call_view_model.dart'; +import 'package:dieklingel_app/utils/microphone_state.dart'; +import 'package:dieklingel_app/utils/speaker_state.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +class CallView extends StatelessWidget { + const CallView({super.key}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + _Video(), + SafeArea( + child: Stack( + children: [ + _Toolbar(), + ], + ), + ), + ], + ); + } +} + +class _Toolbar extends StatelessWidget { + @override + Widget build(BuildContext context) { + final isInCall = context.select( + (value) => value.isInCall, + ); + + return Align( + alignment: Alignment.bottomCenter, + child: BlocBuilder( + builder: (context, state) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.max, + children: [ + _ToolbarButton( + icon: const Icon( + CupertinoIcons.phone_fill, + color: Colors.white, + size: 30, + ), + color: isInCall ? Colors.red : Colors.green, + onPressed: () { + if (isInCall) { + context.read().hangup(); + } else { + context.read().dial(); + } + }, + ), + _ToolbarButton( + icon: IconBuilder( + values: { + MicrophoneState.muted: + const Icon(CupertinoIcons.mic_slash_fill), + MicrophoneState.unmuted: const Icon(CupertinoIcons.mic_fill) + }, + fallback: const Icon(CupertinoIcons.mic_slash_fill), + id: state is CallActiveState ? state.microphoneState : null, + ).build( + color: Colors.white, + size: 30, + ), + color: MapBuilder( + values: { + MicrophoneState.muted: Colors.green, + MicrophoneState.unmuted: Colors.red, + }, + fallback: Colors.green, + id: state is CallActiveState ? state.microphoneState : null, + ).build(), + onPressed: state is CallActiveState + ? () { + context + .read() + .add(CallToogleMicrophone()); + } + : null, + ), + _ToolbarButton( + icon: IconBuilder( + values: { + SpeakerState.muted: + const Icon(CupertinoIcons.speaker_slash_fill), + SpeakerState.headphone: + const Icon(CupertinoIcons.speaker_1_fill), + SpeakerState.speaker: + const Icon(CupertinoIcons.speaker_3_fill), + }, + fallback: const Icon(CupertinoIcons.speaker_slash_fill), + id: state is CallActiveState ? state.speakerState : null, + ).build( + color: Colors.white, + size: 30, + ), + color: MapBuilder( + values: { + SpeakerState.muted: Colors.green, + SpeakerState.headphone: Colors.orange, + SpeakerState.speaker: Colors.red, + }, + fallback: Colors.green, + id: state is CallActiveState ? state.speakerState : null, + ).build(), + onPressed: state is CallActiveState + ? () { + context.read().add(CallToogleSpeaker()); + } + : null, + ), + _ToolbarButton( + icon: const Icon( + CupertinoIcons.lock_fill, + color: Colors.white, + size: 30, + ), + color: Colors.amber, + onPressed: () { + //TODO: context.read().add(HomeUnlock()); + showCupertinoDialog( + context: context, + builder: (BuildContext context) { + Future.delayed(const Duration(milliseconds: 600), () { + Navigator.of(context).pop(); + }); + + return Center( + child: Icon( + CupertinoIcons.lock_open_fill, + size: 150, + color: Colors.green.shade400, + ), + ); + }, + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +class _ToolbarButton extends StatelessWidget { + final Icon icon; + final Color color; + final void Function()? onPressed; + + const _ToolbarButton({ + required this.icon, + required this.color, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return CupertinoButton( + onPressed: onPressed, + child: Container( + padding: const EdgeInsets.all(15.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: onPressed == null ? Colors.black26 : color, + ), + child: icon, + ), + ); + } +} + +class _Video extends StatelessWidget { + @override + Widget build(BuildContext context) { + final renderer = context.select( + (value) => value.renderer, + ); + + if (renderer == null) { + return Container(); + } + + final videoAvailable = context.select( + (value) => !value.isConnecting, + ); + + if (!videoAvailable) { + return const Center( + child: CupertinoActivityIndicator(), + ); + } + + return ValueListenableBuilder( + valueListenable: renderer, + builder: (c, v, w) => InteractiveViewer( + child: RTCVideoView( + renderer, + ), + ), + ); + } +} diff --git a/lib/ui/views/core_view.dart b/lib/ui/views/core_view.dart new file mode 100644 index 0000000..9648b0d --- /dev/null +++ b/lib/ui/views/core_view.dart @@ -0,0 +1,50 @@ +import 'package:dieklingel_app/models/hive_home.dart'; +import 'package:dieklingel_app/repositories/ice_server_repository.dart'; +import 'package:dieklingel_app/ui/view_models/call_view_model.dart'; +import 'package:dieklingel_app/ui/view_models/core_view_model.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:mqtt/mqtt.dart'; +import 'package:provider/provider.dart'; + +import 'call_view.dart'; + +class CoreView extends StatelessWidget { + const CoreView({super.key}); + + @override + Widget build(BuildContext context) { + final connectionErrorMessage = context.select( + (value) => value.connectionErrorMessage, + ); + if (connectionErrorMessage != null) { + return Center( + child: Text(connectionErrorMessage), + ); + } + + final isConnected = context.select( + (value) => value.isConnected, + ); + if (!isConnected) { + return const Center( + child: CupertinoActivityIndicator(), + ); + } + + final client = context.select( + (value) => value.client, + ); + final home = context.select( + (value) => value.home, + ); + + return ChangeNotifierProvider( + create: (_) => CallViewModel( + home, + client, + context.read(), + ), + child: const CallView(), + ); + } +} diff --git a/lib/ui/views/home_view.dart b/lib/ui/views/home_view.dart index eb3dcae..7497b4d 100644 --- a/lib/ui/views/home_view.dart +++ b/lib/ui/views/home_view.dart @@ -1,11 +1,13 @@ +import 'package:dieklingel_app/ui/view_models/core_view_model.dart'; import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; import 'package:dieklingel_app/states/call_state.dart'; -import 'package:dieklingel_app/views/call_view.dart'; +import 'package:dieklingel_app/ui/views/core_view.dart'; import 'package:dieklingel_app/views/home_add_view.dart'; import 'package:dieklingel_app/views/ice_server_add_view.dart'; import 'package:dieklingel_app/views/settings_view.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mqtt/mqtt.dart'; +import 'package:provider/provider.dart'; import 'package:pull_down_button/pull_down_button.dart'; import '../../blocs/call_view_bloc.dart'; @@ -58,6 +60,9 @@ class HomeView extends StatelessWidget { final homes = context.select>((value) => value.homes); + final selectedHome = + context.select((value) => value.home); + return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( middle: Text(title), @@ -80,11 +85,9 @@ class HomeView extends StatelessWidget { ], for (HiveHome home in homes) ...[ PullDownMenuItem.selectable( - selected: context.select( - (value) => value.home) == - home, + selected: selectedHome == home, onTap: () { - context.read().home = home; + //context.read().home = home; context.read().add(CallHangup()); }, title: home.name, @@ -140,7 +143,10 @@ class _Content extends StatelessWidget { ); } - return const CallView(); + return ChangeNotifierProvider( + create: (context) => CoreViewModel(home, MqttClient(home.uri)), + child: const CoreView(), + ); } } diff --git a/lib/views/call_view.dart b/lib/views/call_view.dart deleted file mode 100644 index 6e40a4f..0000000 --- a/lib/views/call_view.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'package:dieklingel_app/blocs/call_view_bloc.dart'; -import 'package:dieklingel_app/components/icon_builder.dart'; -import 'package:dieklingel_app/components/map_builder.dart'; -import 'package:dieklingel_app/states/call_state.dart'; -import 'package:dieklingel_app/utils/microphone_state.dart'; -import 'package:dieklingel_app/utils/speaker_state.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; - -class CallView extends StatelessWidget { - const CallView({super.key}); - - @override - Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state is CallCancelState) { - showCupertinoDialog( - context: context, - builder: (_) => CupertinoAlertDialog( - title: const Text("Error"), - content: Text(state.reason), - actions: [ - CupertinoDialogAction( - child: const Text("Ok"), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - } - }, - child: Stack( - children: [ - _Video(), - SafeArea( - child: Stack( - children: [ - _Toolbar(), - ], - ), - ), - ], - ), - ); - } -} - -class _Toolbar extends StatelessWidget { - List buttons(BuildContext context, CallState state) { - return [ - _ToolbarButton( - icon: const Icon( - CupertinoIcons.phone_fill, - color: Colors.white, - size: 30, - ), - color: MapBuilder( - values: { - CallActiveState: Colors.red, - CallInitatedState: Colors.red, - }, - fallback: Colors.green, - id: state.runtimeType, - ).build(), - onPressed: () { - context.read().add( - state is CallActiveState || state is CallInitatedState - ? CallHangup() - : CallStart(), - ); - }, - ), - _ToolbarButton( - icon: IconBuilder( - values: { - MicrophoneState.muted: const Icon(CupertinoIcons.mic_slash_fill), - MicrophoneState.unmuted: const Icon(CupertinoIcons.mic_fill) - }, - fallback: const Icon(CupertinoIcons.mic_slash_fill), - id: state is CallActiveState ? state.microphoneState : null, - ).build( - color: Colors.white, - size: 30, - ), - color: MapBuilder( - values: { - MicrophoneState.muted: Colors.green, - MicrophoneState.unmuted: Colors.red, - }, - fallback: Colors.green, - id: state is CallActiveState ? state.microphoneState : null, - ).build(), - onPressed: state is CallActiveState - ? () { - context.read().add(CallToogleMicrophone()); - } - : null, - ), - _ToolbarButton( - icon: IconBuilder( - values: { - SpeakerState.muted: const Icon(CupertinoIcons.speaker_slash_fill), - SpeakerState.headphone: const Icon(CupertinoIcons.speaker_1_fill), - SpeakerState.speaker: const Icon(CupertinoIcons.speaker_3_fill), - }, - fallback: const Icon(CupertinoIcons.speaker_slash_fill), - id: state is CallActiveState ? state.speakerState : null, - ).build( - color: Colors.white, - size: 30, - ), - color: MapBuilder( - values: { - SpeakerState.muted: Colors.green, - SpeakerState.headphone: Colors.orange, - SpeakerState.speaker: Colors.red, - }, - fallback: Colors.green, - id: state is CallActiveState ? state.speakerState : null, - ).build(), - onPressed: state is CallActiveState - ? () { - context.read().add(CallToogleSpeaker()); - } - : null, - ), - _ToolbarButton( - icon: const Icon( - CupertinoIcons.lock_fill, - color: Colors.white, - size: 30, - ), - color: Colors.amber, - onPressed: () { - //TODO: context.read().add(HomeUnlock()); - showCupertinoDialog( - context: context, - builder: (BuildContext context) { - Future.delayed(const Duration(milliseconds: 600), () { - Navigator.of(context).pop(); - }); - - return Center( - child: Icon( - CupertinoIcons.lock_open_fill, - size: 150, - color: Colors.green.shade400, - ), - ); - }, - ); - }, - ), - ]; - } - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.bottomCenter, - child: BlocBuilder( - builder: (context, state) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.max, - children: buttons(context, state), - ); - }, - ), - ); - } -} - -class _ToolbarButton extends StatelessWidget { - final Icon icon; - final Color color; - final void Function()? onPressed; - - const _ToolbarButton({ - required this.icon, - required this.color, - this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return CupertinoButton( - onPressed: onPressed, - child: Container( - padding: const EdgeInsets.all(15.0), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: onPressed == null ? Colors.black26 : color, - ), - child: icon, - ), - ); - } -} - -class _Video extends StatelessWidget { - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - if (state is CallInitatedState) { - return const Center( - child: CupertinoActivityIndicator(), - ); - } - - if (state is CallActiveState) { - return ValueListenableBuilder( - valueListenable: state.renderer, - builder: (c, v, w) => InteractiveViewer( - child: RTCVideoView( - state.renderer, - ), - ), - ); - } - - return Container(); - }, - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index 57c9392..67d95c8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.2" + blueprint: + dependency: "direct main" + description: + name: blueprint + sha256: d30dc7123090d7ee97fa30daf2aeb496964340358d0d4aa6077d8852bb984058 + url: "https://pub.dev" + source: hosted + version: "0.0.3" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f3a49d7..da66354 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: path: ^1.8.3 mqtt_client: ^10.0.0 provider: ^6.0.5 + blueprint: ^0.0.3 dev_dependencies: flutter_test: From 7a6bd0aa62113b1da4014b5379535fd152edfd21 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Sun, 5 Nov 2023 22:41:55 +0100 Subject: [PATCH 08/35] feat: use global call handling --- lib/handlers/call.dart | 92 ++++++++++++++++++++++++ lib/handlers/call_kit.dart | 5 ++ lib/models/messages/message_header.dart | 10 +-- lib/ui/view_models/call_view_model.dart | 96 ++++++++++++------------- 4 files changed, 146 insertions(+), 57 deletions(-) create mode 100644 lib/handlers/call.dart create mode 100644 lib/handlers/call_kit.dart diff --git a/lib/handlers/call.dart b/lib/handlers/call.dart new file mode 100644 index 0000000..4d2e485 --- /dev/null +++ b/lib/handlers/call.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import '../models/ice_server.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +class Call extends ChangeNotifier { + final String id; + final List iceServers; + final RTCVideoRenderer renderer = RTCVideoRenderer(); + final StreamController _localIceCandidates = + StreamController(); + final StreamController _remoteIceCandidates = + StreamController(); + + RTCPeerConnection? connection; + + Call( + this.id, + this.iceServers, + ) { + WidgetsFlutterBinding.ensureInitialized(); + _remoteIceCandidates.stream.listen((event) { + connection!.addCandidate(event); + }); + } + + Future offer() async { + await renderer.initialize(); + + connection = await createPeerConnection({ + "iceServers": iceServers.map((e) => e.toMap()).toList(), + "sdpSemantics": "unified-plan", + }); + + connection! + ..onIceCandidate = (candidate) { + _localIceCandidates.add(candidate); + } + ..onTrack = (event) { + if (event.streams.isEmpty) { + return; + } + + renderer.srcObject = event.streams.first; + } + ..onConnectionState = (state) { + notifyListeners(); + }; + + await connection!.addTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeAudio, + init: RTCRtpTransceiverInit(direction: TransceiverDirection.SendRecv), + ); + await connection!.addTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, + init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), + ); + + final offer = await connection!.createOffer(); + await connection!.setLocalDescription(offer); + return offer; + } + + Future withRemoteAnswer(RTCSessionDescription answer) async { + await connection!.setRemoteDescription(answer); + } + + RTCPeerConnectionState get state { + final connectionState = connection?.connectionState; + if (connectionState == null) { + return RTCPeerConnectionState.RTCPeerConnectionStateClosed; + } + + return connectionState; + } + + Stream get localIceCandidates { + return _localIceCandidates.stream; + } + + Sink get remoteIceCandidates { + return _remoteIceCandidates.sink; + } + + Future close() async { + _localIceCandidates.close(); + _remoteIceCandidates.close(); + renderer.dispose(); + } +} diff --git a/lib/handlers/call_kit.dart b/lib/handlers/call_kit.dart new file mode 100644 index 0000000..04eeed7 --- /dev/null +++ b/lib/handlers/call_kit.dart @@ -0,0 +1,5 @@ +import 'package:dieklingel_app/handlers/call.dart'; + +class CallKit { + static Map calls = {}; +} diff --git a/lib/models/messages/message_header.dart b/lib/models/messages/message_header.dart index e221e13..cb8cea3 100644 --- a/lib/models/messages/message_header.dart +++ b/lib/models/messages/message_header.dart @@ -2,11 +2,11 @@ import 'package:blueprint/blueprint.dart'; class MessageHeader { final String senderDeviceId; - final String sessionId; + final String senderSessionId; MessageHeader({ required this.senderDeviceId, - required this.sessionId, + required this.senderSessionId, }); factory MessageHeader.fromMap(dynamic map) { @@ -14,21 +14,21 @@ class MessageHeader { map, { "senderDeviceId": StringF, - "sessionId": StringF, + "senderSessionId": StringF, }, throwable: true, ); return MessageHeader( senderDeviceId: map["senderDeviceId"], - sessionId: map["sessionId"], + senderSessionId: map["senderSessionId"], ); } Map toMap() { return { "senderDeviceId": senderDeviceId, - "sessionId": sessionId, + "senderSessionId": senderSessionId, }; } } diff --git a/lib/ui/view_models/call_view_model.dart b/lib/ui/view_models/call_view_model.dart index c924577..65ddf3c 100644 --- a/lib/ui/view_models/call_view_model.dart +++ b/lib/ui/view_models/call_view_model.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:dieklingel_app/handlers/call.dart'; +import 'package:dieklingel_app/handlers/call_kit.dart'; import 'package:dieklingel_app/models/hive_home.dart'; import 'package:dieklingel_app/models/messages/answer_message.dart'; import 'package:dieklingel_app/models/messages/candidate_message.dart'; @@ -10,8 +12,6 @@ import 'package:dieklingel_app/models/messages/offer_message.dart'; import 'package:dieklingel_app/models/messages/session_message_body.dart'; import 'package:dieklingel_app/models/messages/session_message_header.dart'; import 'package:dieklingel_app/repositories/ice_server_repository.dart'; -import 'package:dieklingel_app/utils/rtc_client_wrapper.dart'; -import 'package:dieklingel_app/utils/rtc_transceiver.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:mqtt/mqtt.dart'; @@ -23,7 +23,6 @@ class CallViewModel extends ChangeNotifier { final MqttClient client; final IceServerRepository iceServerRepository; - RtcClientWrapper? _call; String _remoteSessionId = ""; String _localSessionId = ""; @@ -31,15 +30,16 @@ class CallViewModel extends ChangeNotifier { client.subscribe( "${home.username}/connections/answer", (topic, message) async { - final call = _call; - if (call == null) { - return; - } - try { final payload = AnswerMessage.fromMap(json.decode(message)); _remoteSessionId = payload.header.senderSessionId; - await call.setRemoteDescription(payload.body.sessionDescription); + final call = CallKit.calls[payload.header.sessionId]; + if (call == null) { + print("answer without call"); + return; + } + + await call.withRemoteAnswer(payload.body.sessionDescription); } catch (e) { print(e); } @@ -49,14 +49,15 @@ class CallViewModel extends ChangeNotifier { client.subscribe( "${home.username}/connections/candidate", (topic, message) { - final call = _call; - if (call == null) { - return; - } - try { final payload = CandidateMessage.fromMap(json.decode(message)); - call.addIceCandidate(payload.body.iceCandidate); + final call = CallKit.calls[_localSessionId]; + if (call == null) { + print("candidate without call"); + return; + } + + call.remoteIceCandidates.add(payload.body.iceCandidate); } catch (e) { print(e); } @@ -66,15 +67,18 @@ class CallViewModel extends ChangeNotifier { client.subscribe( "${home.username}/connections/close", (topic, message) async { - final call = _call; - if (call == null) { - return; - } - try { - CloseMessage.fromMap(json.decode(message)); - await call.dispose(); - _call = null; + final payload = CloseMessage.fromMap(json.decode(message)); + final call = CallKit.calls[payload.header.sessionId]; + if (call == null) { + print("close without call"); + return; + } + + await call.close(); + CallKit.calls.remove(payload.header.sessionId); + _localSessionId = ""; + _remoteSessionId = ""; } catch (e) { print(e); } @@ -83,50 +87,36 @@ class CallViewModel extends ChangeNotifier { } bool get isInCall { - return _call != null; + return CallKit.calls[_localSessionId] != null; } bool get isConnecting { - final call = _call; + final call = CallKit.calls[_localSessionId]; if (call == null) { return false; } - return call.state.value != - RTCPeerConnectionState.RTCPeerConnectionStateConnected; + return call.state == + RTCPeerConnectionState.RTCPeerConnectionStateConnecting; } RTCVideoRenderer? get renderer { - return _call?.renderer; + return CallKit.calls[_localSessionId]?.renderer; } void dial() async { _localSessionId = const Uuid().v4(); - final call = await RtcClientWrapper.create( - uuid: _localSessionId, - iceServers: iceServerRepository.servers, - transceivers: [ - RtcTransceiver( - kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, - direction: TransceiverDirection.RecvOnly, - ), - RtcTransceiver( - kind: RTCRtpMediaType.RTCRtpMediaTypeAudio, - direction: TransceiverDirection.SendRecv, - ), - ], - ); - call.state.addListener(() { - print(call.state.value); + final call = Call(_localSessionId, iceServerRepository.servers); + call.addListener(() { notifyListeners(); }); - _call = call; + CallKit.calls[_localSessionId] = call; notifyListeners(); final offer = await call.offer(); final payload = OfferMessage( header: MessageHeader( senderDeviceId: home.username ?? "", - sessionId: call.uuid, + senderSessionId: call.id, ), body: SessionMessageBody( sessionDescription: offer, @@ -138,7 +128,7 @@ class CallViewModel extends ChangeNotifier { json.encode(payload.toMap()), ); - call.onIceCandidate((p0) { + call.localIceCandidates.listen((candidate) { final payload = CandidateMessage( header: SessionMessageHeader( senderDeviceId: home.username ?? "", @@ -146,7 +136,7 @@ class CallViewModel extends ChangeNotifier { sessionId: _remoteSessionId, ), body: CandidateMessageBody( - iceCandidate: p0, + iceCandidate: candidate, ), ); @@ -158,20 +148,22 @@ class CallViewModel extends ChangeNotifier { } void hangup() async { - final call = _call; + final call = CallKit.calls[_localSessionId]; if (call == null) { return; } final payload = CloseMessage( header: SessionMessageHeader( senderDeviceId: home.username ?? "", - senderSessionId: call.uuid, + senderSessionId: call.id, sessionId: _remoteSessionId, ), ); - await call.dispose(); - _call = null; + await call.close(); + CallKit.calls.remove(_localSessionId); + _localSessionId = ""; + _remoteSessionId = ""; notifyListeners(); client.publish( From ed1f828c4f6e4721b3c38ad43860fce65af9b677 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Sun, 5 Nov 2023 22:49:22 +0100 Subject: [PATCH 09/35] fix: inital video loading indicator --- lib/ui/view_models/call_view_model.dart | 9 --------- lib/ui/views/call_view.dart | 3 ++- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/ui/view_models/call_view_model.dart b/lib/ui/view_models/call_view_model.dart index 65ddf3c..14ed9d3 100644 --- a/lib/ui/view_models/call_view_model.dart +++ b/lib/ui/view_models/call_view_model.dart @@ -90,15 +90,6 @@ class CallViewModel extends ChangeNotifier { return CallKit.calls[_localSessionId] != null; } - bool get isConnecting { - final call = CallKit.calls[_localSessionId]; - if (call == null) { - return false; - } - return call.state == - RTCPeerConnectionState.RTCPeerConnectionStateConnecting; - } - RTCVideoRenderer? get renderer { return CallKit.calls[_localSessionId]?.renderer; } diff --git a/lib/ui/views/call_view.dart b/lib/ui/views/call_view.dart index e43f1dc..77b4b3d 100644 --- a/lib/ui/views/call_view.dart +++ b/lib/ui/views/call_view.dart @@ -194,7 +194,8 @@ class _Video extends StatelessWidget { } final videoAvailable = context.select( - (value) => !value.isConnecting, + (value) => + value.renderer?.srcObject?.getVideoTracks().isNotEmpty ?? false, ); if (!videoAvailable) { From 8b07c12db18e7bdb6cae123fde4efbc656b731af Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Sun, 5 Nov 2023 23:12:03 +0100 Subject: [PATCH 10/35] fix: remove print calls --- lib/ui/view_models/call_view_model.dart | 10 ++++------ lib/ui/views/call_view.dart | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/ui/view_models/call_view_model.dart b/lib/ui/view_models/call_view_model.dart index 14ed9d3..43e41a7 100644 --- a/lib/ui/view_models/call_view_model.dart +++ b/lib/ui/view_models/call_view_model.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:dieklingel_app/handlers/call.dart'; import 'package:dieklingel_app/handlers/call_kit.dart'; @@ -35,13 +36,12 @@ class CallViewModel extends ChangeNotifier { _remoteSessionId = payload.header.senderSessionId; final call = CallKit.calls[payload.header.sessionId]; if (call == null) { - print("answer without call"); return; } await call.withRemoteAnswer(payload.body.sessionDescription); } catch (e) { - print(e); + log("could not parse the answer message; message: $message, error: $e"); } }, ); @@ -53,13 +53,12 @@ class CallViewModel extends ChangeNotifier { final payload = CandidateMessage.fromMap(json.decode(message)); final call = CallKit.calls[_localSessionId]; if (call == null) { - print("candidate without call"); return; } call.remoteIceCandidates.add(payload.body.iceCandidate); } catch (e) { - print(e); + log("could not parse the candidate message; message: $message, error: $e"); } }, ); @@ -71,7 +70,6 @@ class CallViewModel extends ChangeNotifier { final payload = CloseMessage.fromMap(json.decode(message)); final call = CallKit.calls[payload.header.sessionId]; if (call == null) { - print("close without call"); return; } @@ -80,7 +78,7 @@ class CallViewModel extends ChangeNotifier { _localSessionId = ""; _remoteSessionId = ""; } catch (e) { - print(e); + log("could not parse the close message; message: $message, error: $e"); } }, ); diff --git a/lib/ui/views/call_view.dart b/lib/ui/views/call_view.dart index 77b4b3d..478a712 100644 --- a/lib/ui/views/call_view.dart +++ b/lib/ui/views/call_view.dart @@ -128,7 +128,7 @@ class _Toolbar extends StatelessWidget { ), color: Colors.amber, onPressed: () { - //TODO: context.read().add(HomeUnlock()); + //TODO: send unlock trigger showCupertinoDialog( context: context, builder: (BuildContext context) { From eb9eb9d41eb9b344041506ebb646b4a85b03e8d1 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Sun, 12 Nov 2023 21:24:40 +0100 Subject: [PATCH 11/35] WIP feat: add all homes to home homeview --- lib/blocs/call_view_bloc.dart | 271 ------------------ lib/blocs/home_add_view_bloc.dart | 3 +- lib/components/core_home_widget.dart | 144 ++++++++++ lib/main.dart | 6 +- lib/repositories/home_repository.dart | 44 +-- lib/ui/view_models/call_view_model.dart | 2 +- lib/ui/view_models/core_view_model.dart | 2 +- lib/ui/view_models/home_view_model.dart | 56 +++- lib/ui/views/core_view.dart | 2 +- lib/ui/views/home_view.dart | 84 ++---- packages/mqtt/lib/mqtt.dart | 3 +- .../lib/src/{mqtt_client.dart => client.dart} | 45 ++- packages/mqtt/lib/src/connnection_state.dart | 6 +- packages/mqtt/lib/src/subscription.dart | 4 +- pubspec.lock | 2 +- pubspec.yaml | 3 +- 16 files changed, 275 insertions(+), 402 deletions(-) delete mode 100644 lib/blocs/call_view_bloc.dart create mode 100644 lib/components/core_home_widget.dart rename packages/mqtt/lib/src/{mqtt_client.dart => client.dart} (73%) diff --git a/lib/blocs/call_view_bloc.dart b/lib/blocs/call_view_bloc.dart deleted file mode 100644 index f837911..0000000 --- a/lib/blocs/call_view_bloc.dart +++ /dev/null @@ -1,271 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:async/async.dart'; -import 'package:dieklingel_app/models/hive_home.dart'; -import 'package:dieklingel_app/models/request.dart'; -import 'package:dieklingel_app/models/response.dart'; -import 'package:dieklingel_app/repositories/home_repository.dart'; -import 'package:dieklingel_app/repositories/ice_server_repository.dart'; -import 'package:dieklingel_app/states/call_state.dart'; -import 'package:dieklingel_app/utils/speaker_state.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; -import 'package:mqtt/mqtt.dart'; -import 'package:mqtt_client/mqtt_client.dart' as mqtt; -import 'package:uuid/uuid.dart'; -import 'package:path/path.dart' as path; - -import '../utils/rtc_client_wrapper.dart'; -import '../utils/rtc_transceiver.dart'; - -class CallViewBloc extends Bloc { - final HomeRepository homeRepository; - final IceServerRepository iceServerRepository; - RtcClientWrapper? rtcclient; - CancelableOperation? _requestOperation; - MqttClient? client; - Subscription? candidateSub; - - CallViewBloc( - this.homeRepository, - this.iceServerRepository, - ) : super(CallState()) { - on(_onStart); - on(_onHangup); - on(_onToogleMicrophone); - on(_onToogleSpeaker); - } - - HiveHome get home { - HiveHome? home = homeRepository.selected; - if (home == null) { - throw Exception( - "cannot read the selected home. Please select a Home in HomeRepository first", - ); - } - return home; - } - - Future _onStart(CallStart event, Emitter emit) async { - emit(CallInitatedState()); - - final MqttClient client = MqttClient(home.uri); - try { - await client.connect( - username: home.username ?? "", - password: home.password ?? "", - ); - this.client = client; - } on mqtt.NoConnectionException catch (exception) { - emit(CallCancelState(exception.toString())); - return; - } - - String uuid = const Uuid().v4(); - - // create rtc connection - rtcclient = await RtcClientWrapper.create( - uuid: uuid, - iceServers: iceServerRepository.servers, - transceivers: [ - RtcTransceiver( - kind: RTCRtpMediaType.RTCRtpMediaTypeAudio, - direction: TransceiverDirection.SendRecv, - ), - RtcTransceiver( - kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, - direction: TransceiverDirection.SendRecv, - ) - ], - ); - - candidateSub?.cancel(); - candidateSub = client.subscribe( - path.normalize("./$uuid/connection/candidate"), - (topic, message) { - Map candidate = jsonDecode( - Request.fromMap( - jsonDecode(message), - ).body, - ); - - rtcclient?.addIceCandidate( - RTCIceCandidate( - candidate["candidate"], - candidate["sdpMid"], - candidate["sdpMLineIndex"] as int, - ), - ); - }, - ); - - rtcclient!.onIceCandidate((RTCIceCandidate candidate) { - client.publish( - path.normalize("./${home.uri.path}/rtc/connections/candidate/$uuid"), - Request.withJsonBody("GET", candidate.toMap()).toJsonString(), - ); - }); - - await rtcclient!.ressource.open(true, false); - MediaStream? stream = rtcclient!.ressource.stream; - if (null != stream) { - for (MediaStreamTrack track in stream.getTracks()) { - rtcclient!.connection.addTrack(track, stream); - // Helper.setMicrophoneMute(true, track); - } - } - - if (rtcclient == null) { - return; - } - - await _requestOperation?.cancel(); - - String answerChannel = const Uuid().v4(); - final operation = CancelableOperation.fromFuture( - client - .once( - path.normalize( - "./${home.uri.path}/rtc/connections/create/$uuid/$answerChannel", - ), - timeout: const Duration(seconds: 15), - ) - .catchError((error) => ""), - ); - client.publish( - path.normalize("./${home.uri.path}/rtc/connections/create/$uuid"), - Request.withJsonBody( - "GET", - (await rtcclient!.offer()).toMap(), - ).withAnswerChannel(answerChannel).toJsonString(), - ); - _requestOperation = operation; - - await operation.value.then((value) async { - if (value.isEmpty) { - emit(CallCancelState("the doorunit did not send a repsonse")); - return; - } - - Response response = Response.fromMap(jsonDecode(value)); - if (response.statusCode != 201) { - add(CallHangup()); - return; - } - - final Map answer = jsonDecode(response.body); - final description = RTCSessionDescription( - answer["sdp"], - answer["type"], - ); - await rtcclient!.setRemoteDescription(description); - - RTCVideoRenderer? renderer = rtcclient?.renderer; - if (renderer == null) { - return; - } - - if (state is! CallInitatedState) { - add(CallHangup()); - return; - } - - emit( - CallActiveState( - microphoneState: rtcclient!.microphoneState, - speakerState: rtcclient!.speakerState, - renderer: renderer, - ), - ); - }).onError((exception, stackTrace) async { - emit(CallCancelState( - "the doorunit did not respond to the message", - )); - rtcclient?.ressource.close(); - await rtcclient?.dispose(); - rtcclient = null; - }); - } - - Future _onHangup(CallHangup event, Emitter emit) async { - String? uuid = rtcclient?.uuid; - _requestOperation?.cancel(); - - emit(CallEndedState()); - rtcclient?.ressource.close(); - await rtcclient?.dispose(); - rtcclient = null; - - await client?.publish( - path.normalize("./${home.uri.path}/rtc/connections/close/$uuid"), - Request("GET", "").toJsonString(), - ); - candidateSub?.cancel(); - candidateSub = null; - client?.disconnect(); - client = null; - } - - Future _onToogleMicrophone( - CallToogleMicrophone event, - Emitter emit, - ) async { - if (rtcclient == null) { - return; - } - - rtcclient!.microphoneState = rtcclient!.microphoneState.next(); - - RTCVideoRenderer? renderer = rtcclient?.renderer; - if (renderer == null) { - emit(CallEndedState()); - return; - } - - emit( - CallActiveState( - microphoneState: rtcclient!.microphoneState, - speakerState: rtcclient!.speakerState, - renderer: renderer, - ), - ); - } - - Future _onToogleSpeaker( - CallToogleSpeaker event, - Emitter emit, - ) async { - if (rtcclient == null) { - return; - } - - rtcclient!.speakerState = rtcclient!.speakerState.next( - skip: [if (kIsWeb) SpeakerState.headphone], - ); - - RTCVideoRenderer? renderer = rtcclient?.renderer; - if (renderer == null) { - emit(CallEndedState()); - return; - } - - emit( - CallActiveState( - microphoneState: rtcclient!.microphoneState, - speakerState: rtcclient!.speakerState, - renderer: renderer, - ), - ); - } - - @override - Future close() async { - candidateSub?.cancel(); - client?.disconnect(); - client = null; - await rtcclient?.dispose(); - return super.close(); - } -} diff --git a/lib/blocs/home_add_view_bloc.dart b/lib/blocs/home_add_view_bloc.dart index fad44e5..1586b84 100644 --- a/lib/blocs/home_add_view_bloc.dart +++ b/lib/blocs/home_add_view_bloc.dart @@ -65,7 +65,7 @@ class HomeAddViewBloc extends Bloc { home.passcode = event.passcode; emit(HomeAddLoadingState()); - final client = MqttClient(home.uri); + final client = Client(home.uri); try { await client.connect( username: home.username ?? "", @@ -81,7 +81,6 @@ class HomeAddViewBloc extends Bloc { } await homeRepository.add(home); - await homeRepository.select(home); emit(HomeAddSuccessfulState()); Box settingsBox = Hive.box("settings"); diff --git a/lib/components/core_home_widget.dart b/lib/components/core_home_widget.dart new file mode 100644 index 0000000..8282868 --- /dev/null +++ b/lib/components/core_home_widget.dart @@ -0,0 +1,144 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mqtt/mqtt.dart' as mqtt; +import 'package:mqtt_client/mqtt_client.dart'; + +import '../models/home.dart'; + +class CoreHomeWidget extends StatefulWidget { + final Home home; + final mqtt.Client client; + final Function? onCallPressed; + final Function? onUnlockPressed; + + const CoreHomeWidget({ + super.key, + required this.home, + required this.client, + this.onCallPressed, + this.onUnlockPressed, + }); + + @override + State createState() => _CoreHomeWidgetState(); +} + +class _CoreHomeWidgetState extends State { + mqtt.ConnectionState _connectionState = MqttConnectionState.faulted; + + @override + void initState() { + widget.client.onConnectionStateChanged = (state) { + setState(() { + _connectionState = state; + }); + }; + + _connectionState = widget.client.state; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8.0), + decoration: const BoxDecoration( + color: CupertinoColors.tertiarySystemGroupedBackground, + borderRadius: BorderRadius.all( + Radius.circular(16.0), + ), + ), + child: Column( + children: [ + Row( + children: [ + Text( + widget.home.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () { + widget.client.connect( + username: widget.home.username ?? "", + password: widget.home.password ?? "", + ); + }, + child: const Icon( + CupertinoIcons.restart, + size: 20, + color: CupertinoColors.secondaryLabel, + ), + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: _ConnectionState(_connectionState), + ), + CupertinoButton( + padding: EdgeInsets.zero, + borderRadius: const BorderRadius.all(Radius.circular(999)), + color: Colors.green, + onPressed: _connectionState == mqtt.ConnectionState.connected + ? () => widget.onCallPressed?.call() + : null, + child: const Icon( + CupertinoIcons.phone_fill, + ), + ), + const SizedBox(width: 6.0), + CupertinoButton( + padding: EdgeInsets.zero, + borderRadius: const BorderRadius.all(Radius.circular(999)), + color: Colors.amber, + onPressed: _connectionState == mqtt.ConnectionState.connected + ? () => widget.onUnlockPressed?.call() + : null, + child: const Icon( + CupertinoIcons.lock_fill, + ), + ), + ], + ) + ], + ), + ); + } +} + +class _ConnectionState extends StatelessWidget { + final mqtt.ConnectionState state; + + const _ConnectionState(this.state); + + @override + Widget build(BuildContext context) { + String message; + switch (state) { + case mqtt.ConnectionState.disconnected: + message = "disconnected"; + break; + case mqtt.ConnectionState.connecting: + message = "connecting"; + break; + case mqtt.ConnectionState.connected: + message = "connected"; + break; + case mqtt.ConnectionState.disconnecting: + message = "disconnecting"; + break; + case mqtt.ConnectionState.faulted: + message = "could not connect"; + break; + } + + return Text( + message, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 4456a5a..5c5cc27 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,3 @@ -import 'package:dieklingel_app/blocs/call_view_bloc.dart'; import 'package:dieklingel_app/blocs/home_add_view_bloc.dart'; import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; import 'package:dieklingel_app/handlers/notification_handler.dart'; @@ -59,9 +58,6 @@ void main() async { BlocProvider(create: (_) => HomeAddViewBloc(homeRepository)), BlocProvider( create: (_) => IceServerAddViewBloc(iceServerRepository)), - BlocProvider( - create: (_) => CallViewBloc(homeRepository, iceServerRepository), - ), ], child: const App(), ), @@ -109,7 +105,7 @@ class _App extends State { Box box = Hive.box((Home).toString()); for (HiveHome home in box.values) { - final client = MqttClient(home.uri); + final client = Client(home.uri); await client.connect( username: home.username ?? "", password: home.password ?? "", diff --git a/lib/repositories/home_repository.dart b/lib/repositories/home_repository.dart index 8bcc4e1..689c059 100644 --- a/lib/repositories/home_repository.dart +++ b/lib/repositories/home_repository.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dieklingel_app/models/hive_home.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -5,28 +7,23 @@ import '../models/home.dart'; class HomeRepository { final Box _homebox = Hive.box((Home).toString()); - final Box _settingsbox = Hive.box("settings"); + final _add = StreamController(); + final _remove = StreamController(); + final _change = StreamController(); List get homes => _homebox.values.toList(); - - HiveHome? get selected { - dynamic key = _settingsbox.get("home"); - if (key == null) { - return null; - } - if (!_homebox.containsKey(key) && _homebox.isNotEmpty) { - select(_homebox.values.first); - return _homebox.values.first; - } - return _homebox.get(key); - } + Stream get added => _add.stream; + Stream get changed => _change.stream; + Stream get removed => _remove.stream; Future add(HiveHome home) async { if (home.isInBox) { await home.save(); + _change.add(home); return; } await _homebox.add(home); + _add.add(home); } Future delete(HiveHome home) async { @@ -34,25 +31,6 @@ class HomeRepository { return; } await home.delete(); - if (homes.isEmpty) { - await select(null); - return; - } - if (selected == home) { - select(homes.first); - } - } - - Future select(HiveHome? home) async { - if (home == null) { - await _settingsbox.delete("home"); - return; - } - if (!homes.contains(home)) { - throw Exception( - "The selected home cannot be selected, because it is not saved!", - ); - } - await _settingsbox.put("home", home.key); + _remove.add(home); } } diff --git a/lib/ui/view_models/call_view_model.dart b/lib/ui/view_models/call_view_model.dart index 43e41a7..04311b7 100644 --- a/lib/ui/view_models/call_view_model.dart +++ b/lib/ui/view_models/call_view_model.dart @@ -21,7 +21,7 @@ import 'package:uuid/uuid.dart'; class CallViewModel extends ChangeNotifier { final HiveHome home; - final MqttClient client; + final Client client; final IceServerRepository iceServerRepository; String _remoteSessionId = ""; diff --git a/lib/ui/view_models/core_view_model.dart b/lib/ui/view_models/core_view_model.dart index 4840e79..1ce59a9 100644 --- a/lib/ui/view_models/core_view_model.dart +++ b/lib/ui/view_models/core_view_model.dart @@ -5,7 +5,7 @@ import 'package:mqtt_client/mqtt_client.dart' as mqtt; class CoreViewModel extends ChangeNotifier { final HiveHome home; - final MqttClient client; + final Client client; bool _isConnected = false; String? _connectionErrorMessage; diff --git a/lib/ui/view_models/home_view_model.dart b/lib/ui/view_models/home_view_model.dart index 9ecf24a..ee6fd80 100644 --- a/lib/ui/view_models/home_view_model.dart +++ b/lib/ui/view_models/home_view_model.dart @@ -1,30 +1,56 @@ -import 'dart:async'; - -import 'package:dieklingel_app/models/hive_home.dart'; +import 'package:dieklingel_app/models/home.dart'; +import 'package:mqtt/mqtt.dart' as mqtt; import 'package:dieklingel_app/repositories/home_repository.dart'; import 'package:flutter/cupertino.dart'; class HomeViewModel extends ChangeNotifier { final HomeRepository homeRepository; + final Map _connections = {}; - HomeViewModel(this.homeRepository); + HomeViewModel(this.homeRepository) { + homeRepository.added.listen((home) async { + _connections[home] = mqtt.Client(home.uri); + await _connections[home]?.connect( + username: home.username ?? "", + password: home.password ?? "", + throws: false, + ); + notifyListeners(); + }); - HiveHome? get home { - return homeRepository.selected; - } + homeRepository.changed.listen((home) async { + _connections[home]?.disconnect(); + _connections[home] = mqtt.Client(home.uri); + await _connections[home]?.connect( + username: home.username ?? "", + password: home.password ?? "", + throws: false, + ); - set home(HiveHome? home) { - (() async { - await homeRepository.select(home); notifyListeners(); - })(); + }); + + homeRepository.removed.listen((home) async { + _connections[home]?.disconnect(); + _connections.remove(home); + notifyListeners(); + }); + + for (final home in homeRepository.homes) { + _connections[home] = mqtt.Client(home.uri); + _connections[home]?.connect( + username: home.username ?? "", + password: home.password ?? "", + throws: false, + ); + } } - List get homes { - return homeRepository.homes; + List get homes { + return _connections.keys.toList(); } - Future refresh() async { - notifyListeners(); + List<(Home, mqtt.Client)> get connections { + return _connections.entries.map((e) => (e.key, e.value)).toList(); } } diff --git a/lib/ui/views/core_view.dart b/lib/ui/views/core_view.dart index 9648b0d..15c3a27 100644 --- a/lib/ui/views/core_view.dart +++ b/lib/ui/views/core_view.dart @@ -31,7 +31,7 @@ class CoreView extends StatelessWidget { ); } - final client = context.select( + final client = context.select( (value) => value.client, ); final home = context.select( diff --git a/lib/ui/views/home_view.dart b/lib/ui/views/home_view.dart index 7497b4d..2b4e045 100644 --- a/lib/ui/views/home_view.dart +++ b/lib/ui/views/home_view.dart @@ -1,23 +1,18 @@ -import 'package:dieklingel_app/ui/view_models/core_view_model.dart'; +import 'package:dieklingel_app/components/core_home_widget.dart'; +import 'package:dieklingel_app/models/home.dart'; import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; -import 'package:dieklingel_app/states/call_state.dart'; -import 'package:dieklingel_app/ui/views/core_view.dart'; import 'package:dieklingel_app/views/home_add_view.dart'; import 'package:dieklingel_app/views/ice_server_add_view.dart'; import 'package:dieklingel_app/views/settings_view.dart'; import 'package:flutter/cupertino.dart'; -import 'package:mqtt/mqtt.dart'; +import 'package:mqtt/mqtt.dart' as mqtt; import 'package:provider/provider.dart'; import 'package:pull_down_button/pull_down_button.dart'; -import '../../blocs/call_view_bloc.dart'; -import '../../models/hive_home.dart'; - class HomeView extends StatelessWidget { const HomeView({super.key}); void _onAddHome(BuildContext context) async { - final homeViewModel = context.read(); await showCupertinoModalPopup( context: context, builder: (context) { @@ -26,11 +21,9 @@ class HomeView extends StatelessWidget { ); }, ); - await homeViewModel.refresh(); } void _onAddIceServer(BuildContext context) async { - final homeViewModel = context.read(); await showCupertinoModalPopup( context: context, builder: (context) { @@ -39,33 +32,21 @@ class HomeView extends StatelessWidget { ); }, ); - homeViewModel.refresh(); } void _onSettingsTap(BuildContext context) async { - final homeViewModel = context.read(); await Navigator.of(context).push( CupertinoPageRoute( builder: (context) => const SettingsView(), ), ); - await homeViewModel.refresh(); } @override Widget build(BuildContext context) { - final title = - context.select((value) => value.home)?.name ?? - "Home"; - final homes = - context.select>((value) => value.homes); - - final selectedHome = - context.select((value) => value.home); - return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( - middle: Text(title), + middle: const Text("Homes"), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -73,33 +54,10 @@ class HomeView extends StatelessWidget { addHomeFunc: _onAddHome, addIceServerFunc: _onAddIceServer, ), - PullDownButton( - itemBuilder: (context) => [ - PullDownMenuItem( - onTap: () => _onSettingsTap(context), - title: "Settings", - icon: CupertinoIcons.settings, - ), - if (homes.isNotEmpty) ...[ - const PullDownMenuDivider.large(), - ], - for (HiveHome home in homes) ...[ - PullDownMenuItem.selectable( - selected: selectedHome == home, - onTap: () { - //context.read().home = home; - context.read().add(CallHangup()); - }, - title: home.name, - ), - if (home != homes.last) ...[const PullDownMenuDivider()], - ] - ], - buttonBuilder: (context, showMenu) => CupertinoButton( - padding: EdgeInsets.zero, - onPressed: showMenu, - child: const Icon(CupertinoIcons.ellipsis_circle), - ), + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => _onSettingsTap(context), + child: const Icon(CupertinoIcons.settings), ), ], ), @@ -111,7 +69,6 @@ class HomeView extends StatelessWidget { class _Content extends StatelessWidget { void _onAddHome(BuildContext context) async { - final homeViewModel = context.read(); await showCupertinoModalPopup( context: context, builder: (context) { @@ -120,15 +77,16 @@ class _Content extends StatelessWidget { ); }, ); - homeViewModel.refresh(); } @override Widget build(BuildContext context) { - HiveHome? home = - context.select((value) => value.home); + List<(Home, mqtt.Client)> connections = + context.select>( + (value) => value.connections, + ); - if (home == null) { + if (connections.isEmpty) { return Center( child: CupertinoButton( child: const Row( @@ -143,9 +101,19 @@ class _Content extends StatelessWidget { ); } - return ChangeNotifierProvider( - create: (context) => CoreViewModel(home, MqttClient(home.uri)), - child: const CoreView(), + return ListView.builder( + itemCount: connections.length, + itemBuilder: (context, index) { + final (home, client) = connections[index]; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: CoreHomeWidget( + home: home, + client: client, + ), + ); + }, ); } } diff --git a/packages/mqtt/lib/mqtt.dart b/packages/mqtt/lib/mqtt.dart index fefacbe..d0b8b55 100644 --- a/packages/mqtt/lib/mqtt.dart +++ b/packages/mqtt/lib/mqtt.dart @@ -1,4 +1,5 @@ library mqtt; -export 'src/mqtt_client.dart'; +export 'src/client.dart'; export 'src/subscription.dart'; +export 'src/connnection_state.dart'; diff --git a/packages/mqtt/lib/src/mqtt_client.dart b/packages/mqtt/lib/src/client.dart similarity index 73% rename from packages/mqtt/lib/src/mqtt_client.dart rename to packages/mqtt/lib/src/client.dart index 317da26..a4ece24 100644 --- a/packages/mqtt/lib/src/mqtt_client.dart +++ b/packages/mqtt/lib/src/client.dart @@ -1,39 +1,70 @@ import 'dart:async'; import 'dart:convert'; +import 'package:mqtt/mqtt.dart'; + import 'factories/mqtt_client_factory.dart'; -import 'subscription.dart'; import 'package:uuid/uuid.dart'; import 'package:mqtt_client/mqtt_client.dart' as mqtt; -class MqttClient { +class Client { final Uri _uri; final mqtt.MqttClient _client; final Map> _subscriptions = {}; + Function(ConnectionState)? onConnectionStateChanged; - MqttClient._(this._client, this._uri) { + Client._(this._client, this._uri) { _client ..port = _uri.port ..keepAlivePeriod = 20 ..setProtocolV311() ..autoReconnect = true; + _client + ..onDisconnected = () { + onConnectionStateChanged?.call(state); + } + ..onConnected = () { + onConnectionStateChanged?.call(state); + } + ..onAutoReconnected = () { + onConnectionStateChanged?.call(state); + } + ..onAutoReconnect = () { + onConnectionStateChanged?.call(state); + }; } - factory MqttClient(Uri uri, {String? identifier}) { + ConnectionState get state { + return _client.connectionStatus?.state ?? ConnectionState.disconnected; + } + + factory Client(Uri uri, {String? identifier}) { final client = const MqttClientFactory().create( uri, identifier ?? const Uuid().v4(), ); - return MqttClient._(client, uri); + return Client._(client, uri); } void disconnect() { _client.disconnect(); } - Future connect({String username = "", String password = ""}) async { - await _client.connect(username, password); + Future connect({ + String username = "", + String password = "", + bool throws = true, + }) async { + try { + await _client.connect(username, password); + } catch (e) { + if (throws) { + rethrow; + } + onConnectionStateChanged?.call(state); + return; + } _client.updates!.listen((event) { mqtt.MqttPublishMessage rec = event[0].payload as mqtt.MqttPublishMessage; final String topic = event[0].topic; diff --git a/packages/mqtt/lib/src/connnection_state.dart b/packages/mqtt/lib/src/connnection_state.dart index 2fa8221..7cd7a48 100644 --- a/packages/mqtt/lib/src/connnection_state.dart +++ b/packages/mqtt/lib/src/connnection_state.dart @@ -1,3 +1,3 @@ -enum ConnectionState { - connected, -} +import 'package:mqtt_client/mqtt_client.dart' as mqtt; + +typedef ConnectionState = mqtt.MqttConnectionState; diff --git a/packages/mqtt/lib/src/subscription.dart b/packages/mqtt/lib/src/subscription.dart index 988ed38..94313fd 100644 --- a/packages/mqtt/lib/src/subscription.dart +++ b/packages/mqtt/lib/src/subscription.dart @@ -1,10 +1,10 @@ -import 'mqtt_client.dart'; +import 'client.dart'; import 'package:mqtt_client/mqtt_client.dart' as mqtt; typedef Callback = void Function(String topic, String message); class Subscription { - final MqttClient client; + final Client client; final Callback callback; final mqtt.Subscription subscription; diff --git a/pubspec.lock b/pubspec.lock index 67d95c8..25fdd44 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -584,7 +584,7 @@ packages: source: hosted version: "0.8.3" rxdart: - dependency: transitive + dependency: "direct main" description: name: rxdart sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" diff --git a/pubspec.yaml b/pubspec.yaml index da66354..43008a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.2.3+1 environment: - sdk: ">=2.18.6 <3.7.11" + sdk: ">=3.0.0 <3.7.11" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -56,6 +56,7 @@ dependencies: mqtt_client: ^10.0.0 provider: ^6.0.5 blueprint: ^0.0.3 + rxdart: ^0.27.7 dev_dependencies: flutter_test: From 97e38a5526bf0a90c4344e6257e00e01ea3b4f16 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Sun, 12 Nov 2023 22:10:01 +0100 Subject: [PATCH 12/35] feat: add outgoing call view --- lib/components/fade_page_route.dart | 24 ++++++++ .../view_models/outgoing_call_view_model.dart | 11 ++++ lib/ui/views/home_view.dart | 16 ++++++ lib/ui/views/outgoing_call_view.dart | 55 +++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 lib/components/fade_page_route.dart create mode 100644 lib/ui/view_models/outgoing_call_view_model.dart create mode 100644 lib/ui/views/outgoing_call_view.dart diff --git a/lib/components/fade_page_route.dart b/lib/components/fade_page_route.dart new file mode 100644 index 0000000..a9c26fd --- /dev/null +++ b/lib/components/fade_page_route.dart @@ -0,0 +1,24 @@ +import 'package:flutter/cupertino.dart'; + +class FadePageRoute extends PageRouteBuilder { + final Widget Function(BuildContext) builder; + + FadePageRoute({required this.builder}) + : super( + pageBuilder: (context, animation, secAnimaton) { + return builder(context); + }, + transitionsBuilder: (context, animation, secAnimation, child) { + final tween = Tween(begin: 0.0, end: 1.0); + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.ease, + ); + + return FadeTransition( + opacity: tween.animate(curvedAnimation), + child: child, + ); + }, + ); +} diff --git a/lib/ui/view_models/outgoing_call_view_model.dart b/lib/ui/view_models/outgoing_call_view_model.dart new file mode 100644 index 0000000..ae6e92f --- /dev/null +++ b/lib/ui/view_models/outgoing_call_view_model.dart @@ -0,0 +1,11 @@ +import 'package:flutter/cupertino.dart'; +import 'package:mqtt/mqtt.dart' as mqtt; + +import '../../models/home.dart'; + +class OutgoingCallViewModel extends ChangeNotifier { + final Home home; + final mqtt.Client connection; + + OutgoingCallViewModel({required this.home, required this.connection}); +} diff --git a/lib/ui/views/home_view.dart b/lib/ui/views/home_view.dart index 2b4e045..62ad1b4 100644 --- a/lib/ui/views/home_view.dart +++ b/lib/ui/views/home_view.dart @@ -1,6 +1,9 @@ import 'package:dieklingel_app/components/core_home_widget.dart'; +import 'package:dieklingel_app/components/fade_page_route.dart'; import 'package:dieklingel_app/models/home.dart'; import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; +import 'package:dieklingel_app/ui/view_models/outgoing_call_view_model.dart'; +import 'package:dieklingel_app/ui/views/outgoing_call_view.dart'; import 'package:dieklingel_app/views/home_add_view.dart'; import 'package:dieklingel_app/views/ice_server_add_view.dart'; import 'package:dieklingel_app/views/settings_view.dart'; @@ -111,6 +114,19 @@ class _Content extends StatelessWidget { child: CoreHomeWidget( home: home, client: client, + onCallPressed: () { + Navigator.of(context).push( + FadePageRoute( + builder: (context) => ChangeNotifierProvider( + create: (context) => OutgoingCallViewModel( + home: home, + connection: client, + ), + child: const OutgoingCallView(), + ), + ), + ); + }, ), ); }, diff --git a/lib/ui/views/outgoing_call_view.dart b/lib/ui/views/outgoing_call_view.dart new file mode 100644 index 0000000..e92b955 --- /dev/null +++ b/lib/ui/views/outgoing_call_view.dart @@ -0,0 +1,55 @@ +import 'package:dieklingel_app/ui/view_models/outgoing_call_view_model.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class OutgoingCallView extends StatelessWidget { + const OutgoingCallView({super.key}); + + @override + Widget build(BuildContext context) { + final callee = context.select( + (value) => value.home.name, + ); + + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.lightBackgroundGray, + child: SafeArea( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(56), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + children: [ + Text( + callee, + style: Theme.of(context).textTheme.headlineSmall, + ), + Text( + "outgoing call...", + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + CupertinoButton( + color: Colors.red, + padding: EdgeInsets.zero, + minSize: kMinInteractiveDimensionCupertino * 1.2, + borderRadius: BorderRadius.circular(999), + child: const Icon( + CupertinoIcons.xmark, + ), + onPressed: () { + Navigator.pop(context); + }, + ) + ], + ), + ), + ), + ); + } +} From 8e23ed0c4a1f9b1c4b8fec9ffe957ef48738007c Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Tue, 21 Nov 2023 18:25:56 +0100 Subject: [PATCH 13/35] wip: use provider --- lib/components/fade_page_route.dart | 2 + lib/handlers/call.dart | 90 ++++++++ lib/models/audio/microphone_state.dart | 4 + lib/models/audio/speaker_state.dart | 5 + .../messages/session_message_header.dart | 2 +- .../view_models/active_call_view_model.dart | 103 +++++++++ .../view_models/outgoing_call_view_model.dart | 116 +++++++++- lib/ui/views/active_call_view.dart | 183 +++++++++++++++ lib/ui/views/call_view.dart | 216 ------------------ lib/ui/views/core_view.dart | 50 ---- lib/ui/views/home_view.dart | 2 + lib/ui/views/outgoing_call_view.dart | 75 +++++- packages/mqtt/lib/src/client.dart | 6 + pubspec.lock | 48 ++++ pubspec.yaml | 2 + 15 files changed, 625 insertions(+), 279 deletions(-) create mode 100644 lib/models/audio/microphone_state.dart create mode 100644 lib/models/audio/speaker_state.dart create mode 100644 lib/ui/view_models/active_call_view_model.dart create mode 100644 lib/ui/views/active_call_view.dart delete mode 100644 lib/ui/views/call_view.dart delete mode 100644 lib/ui/views/core_view.dart diff --git a/lib/components/fade_page_route.dart b/lib/components/fade_page_route.dart index a9c26fd..382a195 100644 --- a/lib/components/fade_page_route.dart +++ b/lib/components/fade_page_route.dart @@ -5,6 +5,8 @@ class FadePageRoute extends PageRouteBuilder { FadePageRoute({required this.builder}) : super( + transitionDuration: const Duration(milliseconds: 150), + reverseTransitionDuration: const Duration(milliseconds: 150), pageBuilder: (context, animation, secAnimaton) { return builder(context); }, diff --git a/lib/handlers/call.dart b/lib/handlers/call.dart index 4d2e485..5bc8a13 100644 --- a/lib/handlers/call.dart +++ b/lib/handlers/call.dart @@ -1,5 +1,9 @@ import 'dart:async'; +import 'package:dieklingel_app/utils/microphone_state.dart'; +import 'package:flutter/foundation.dart'; + +import '../models/audio/speaker_state.dart'; import '../models/ice_server.dart'; import 'package:flutter/material.dart'; @@ -15,6 +19,8 @@ class Call extends ChangeNotifier { StreamController(); RTCPeerConnection? connection; + SpeakerState _speaker = SpeakerState.muted; + MicrophoneState _microphone = MicrophoneState.muted; Call( this.id, @@ -26,6 +32,85 @@ class Call extends ChangeNotifier { }); } + List get loaclAudioTracks { + final conn = connection; + if (conn == null) { + return []; + } + + final List tracks = conn + .getLocalStreams() + .whereType() + .expand( + (stream) => stream.getAudioTracks(), + ) + .toList(); + return tracks; + } + + List get remoteAudioTracks { + final conn = connection; + if (conn == null) { + return []; + } + + final List tracks = conn + .getRemoteStreams() + .whereType() + .expand( + (stream) => stream.getAudioTracks(), + ) + .toList(); + return tracks; + } + + SpeakerState get speaker { + return _speaker; + } + + set speaker(SpeakerState state) { + _speaker = state; + notifyListeners(); + for (final track in remoteAudioTracks) { + switch (_speaker) { + case SpeakerState.muted: + track.enabled = false; + break; + case SpeakerState.earphone: + track.enabled = true; + if (!kIsWeb) { + track.enableSpeakerphone(true); + } + break; + case SpeakerState.speaker: + track.enabled = true; + if (!kIsWeb) { + track.enableSpeakerphone(false); + } + break; + } + } + } + + MicrophoneState get microphone { + return _microphone; + } + + set microphone(MicrophoneState state) { + _microphone = state; + notifyListeners(); + for (final track in loaclAudioTracks) { + switch (_microphone) { + case MicrophoneState.muted: + track.enabled = false; + break; + case MicrophoneState.unmuted: + track.enabled = true; + break; + } + } + } + Future offer() async { await renderer.initialize(); @@ -43,6 +128,11 @@ class Call extends ChangeNotifier { return; } + for (final track in event.streams.first.getAudioTracks()) { + // TODO:set speaker + // track.enabled = !_isSpeakerMuted; + } + renderer.srcObject = event.streams.first; } ..onConnectionState = (state) { diff --git a/lib/models/audio/microphone_state.dart b/lib/models/audio/microphone_state.dart new file mode 100644 index 0000000..e1a49cb --- /dev/null +++ b/lib/models/audio/microphone_state.dart @@ -0,0 +1,4 @@ +enum MicrophoneState { + muted, + unmuted, +} diff --git a/lib/models/audio/speaker_state.dart b/lib/models/audio/speaker_state.dart new file mode 100644 index 0000000..3572952 --- /dev/null +++ b/lib/models/audio/speaker_state.dart @@ -0,0 +1,5 @@ +enum SpeakerState { + muted, + earphone, + speaker, +} diff --git a/lib/models/messages/session_message_header.dart b/lib/models/messages/session_message_header.dart index 903c236..adf8810 100644 --- a/lib/models/messages/session_message_header.dart +++ b/lib/models/messages/session_message_header.dart @@ -25,7 +25,7 @@ class SessionMessageHeader { return SessionMessageHeader( senderDeviceId: map["senderDeviceId"], sessionId: map["sessionId"], - senderSessionId: map["sessionId"], + senderSessionId: map["senderSessionId"], ); } diff --git a/lib/ui/view_models/active_call_view_model.dart b/lib/ui/view_models/active_call_view_model.dart new file mode 100644 index 0000000..a6a36ae --- /dev/null +++ b/lib/ui/view_models/active_call_view_model.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'package:dieklingel_app/models/messages/session_message_header.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:mqtt/mqtt.dart' as mqtt; +import 'package:path/path.dart'; +import '../../handlers/call.dart'; +import '../../models/home.dart'; +import '../../models/messages/candidate_message.dart'; +import '../../models/messages/close_message.dart'; + +class ActiveCallViewModel extends ChangeNotifier { + final Home home; + final mqtt.Client connection; + final Call call; + final Completer _onHangup = Completer(); + final String remoteSessionId; + + ActiveCallViewModel({ + required this.home, + required this.connection, + required this.call, + required this.remoteSessionId, + }) { + call.addListener(_onCallChange); + + connection.subscribe( + "${home.username}/connections/candidate", + (topic, message) { + try { + final payload = CandidateMessage.fromMap(json.decode(message)); + if (payload.header.sessionId != call.id) { + return; + } + + call.remoteIceCandidates.add(payload.body.iceCandidate); + } catch (e) { + log("could not parse the candidate message; message: $message, error: $e"); + } + }, + ); + + connection.subscribe( + "${home.username}/connections/close", + (topic, message) async { + try { + final payload = CloseMessage.fromMap(json.decode(message)); + if (payload.header.sessionId != call.id) { + return; + } + + _onHangup.complete(); + await call.close(); + } catch (e) { + log("could not parse the close message; message: $message, error: $e"); + } + }, + ); + } + + void _onCallChange() { + notifyListeners(); + } + + set isMicrophoneMuted(bool value) { + Helper.setMicrophoneMute( + value, + call.renderer.srcObject!.getAudioTracks().first, + ); + + notifyListeners(); + } + + bool get isMicrophoneMuted { + return false; + } + + Future onHangup() async { + return _onHangup.future; + } + + void hangup() { + call.removeListener(_onCallChange); + call.close(); + + final payload = CloseMessage( + header: SessionMessageHeader( + senderDeviceId: home.username ?? "", + senderSessionId: call.id, + sessionId: remoteSessionId, + ), + ); + + connection.publish( + normalize("./${home.uri.path}/connections/close"), + json.encode(payload.toMap()), + ); + _onHangup.complete(null); + } +} diff --git a/lib/ui/view_models/outgoing_call_view_model.dart b/lib/ui/view_models/outgoing_call_view_model.dart index ae6e92f..c818d5e 100644 --- a/lib/ui/view_models/outgoing_call_view_model.dart +++ b/lib/ui/view_models/outgoing_call_view_model.dart @@ -1,11 +1,125 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:dieklingel_app/repositories/ice_server_repository.dart'; import 'package:flutter/cupertino.dart'; import 'package:mqtt/mqtt.dart' as mqtt; +import 'package:path/path.dart'; +import 'package:uuid/uuid.dart'; +import '../../handlers/call.dart'; import '../../models/home.dart'; +import '../../models/messages/answer_message.dart'; +import '../../models/messages/candidate_message.dart'; +import '../../models/messages/close_message.dart'; +import '../../models/messages/message_header.dart'; +import '../../models/messages/offer_message.dart'; +import '../../models/messages/session_message_body.dart'; class OutgoingCallViewModel extends ChangeNotifier { + final IceServerRepository iceServerRepository; final Home home; final mqtt.Client connection; + final Completer _onHangup = Completer(); + final Completer<(Call, String)> _onAnswer = Completer(); + Timer? _timeout; + + late final Call _call = Call(const Uuid().v4(), iceServerRepository.servers); + + OutgoingCallViewModel({ + required this.home, + required this.connection, + required this.iceServerRepository, + }) { + connection.subscribe( + "${home.username}/connections/answer", + (topic, message) async { + try { + final payload = AnswerMessage.fromMap(json.decode(message)); + if (payload.header.sessionId != _call.id) { + return; + } + + _timeout?.cancel(); + _onAnswer.complete((_call, payload.header.senderSessionId)); + await _call.withRemoteAnswer(payload.body.sessionDescription); + } catch (e) { + log("could not parse the answer message; message: $message, error: $e"); + } + }, + ); + + connection.subscribe( + "${home.username}/connections/candidate", + (topic, message) { + try { + final payload = CandidateMessage.fromMap(json.decode(message)); + if (payload.header.sessionId != _call.id) { + return; + } + + _call.remoteIceCandidates.add(payload.body.iceCandidate); + } catch (e) { + log("could not parse the candidate message; message: $message, error: $e"); + } + }, + ); + + connection.subscribe( + "${home.username}/connections/close", + (topic, message) async { + try { + final payload = CloseMessage.fromMap(json.decode(message)); + if (payload.header.sessionId != _call.id) { + return; + } + + await _call.close(); + _onHangup.complete(null); + } catch (e) { + log("could not parse the close message; message: $message, error: $e"); + } + }, + ); + } + + Future onHangup() { + return _onHangup.future; + } + + Future<(Call, String)> onAnswer() { + return _onAnswer.future; + } + + Future call() async { + if (_timeout != null) { + throw Exception("a call was already in progress"); + } + + final offer = await _call.offer(); + final payload = OfferMessage( + header: MessageHeader( + senderDeviceId: home.username ?? "", + senderSessionId: _call.id, + ), + body: SessionMessageBody( + sessionDescription: offer, + ), + ); + + connection.publish( + normalize("./${home.uri.path}/connections/offer"), + json.encode(payload.toMap()), + ); + _timeout = Timer( + const Duration(seconds: 15), + () => _onHangup.complete(), + ); + } - OutgoingCallViewModel({required this.home, required this.connection}); + void hangup() { + _call.close(); + _onHangup.complete(); + } } diff --git a/lib/ui/views/active_call_view.dart b/lib/ui/views/active_call_view.dart new file mode 100644 index 0000000..d30dfbc --- /dev/null +++ b/lib/ui/views/active_call_view.dart @@ -0,0 +1,183 @@ +import 'package:dieklingel_app/ui/view_models/active_call_view_model.dart'; +import 'package:dieklingel_app/utils/microphone_state.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:provider/provider.dart'; +import 'package:pull_down_button/pull_down_button.dart'; + +import '../../models/audio/speaker_state.dart'; + +class ActiveCallView extends StatefulWidget { + const ActiveCallView({super.key}); + + @override + State createState() => _ActiveCallViewState(); +} + +class _ActiveCallViewState extends State { + bool isEarphone = false; + + @override + void initState() { + super.initState(); + + context.read().onHangup().then((_) { + Navigator.pop(context); + }); + } + + @override + Widget build(BuildContext context) { + final renderer = context.select( + (ActiveCallViewModel value) => value.call.renderer, + ); + + return CupertinoPageScaffold( + child: Stack( + children: [ + InteractiveViewer( + child: RTCVideoView(renderer), + ), + Align( + alignment: Alignment.bottomCenter, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _MicrophoneButton(), + _SpeakerButton(), + const CupertinoButton( + onPressed: null, + child: Icon(CupertinoIcons.lock_fill), + ), + Hero( + tag: "call_hangup_button", + child: CupertinoButton( + color: Colors.red, + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(999), + onPressed: () { + context.read().hangup(); + }, + child: const Icon(CupertinoIcons.xmark), + ), + ) + ], + ), + ) + ], + ), + ); + } +} + +class _MicrophoneButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final microphoneState = context.select( + (ActiveCallViewModel value) => value.call.microphone, + ); + + return PullDownButton( + itemBuilder: (context) { + return [ + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.call.microphone = MicrophoneState.muted; + }, + title: "Muted", + icon: CupertinoIcons.mic_slash_fill, + selected: microphoneState == MicrophoneState.muted, + ), + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.call.microphone = MicrophoneState.unmuted; + }, + title: "Unmuted", + icon: CupertinoIcons.mic_fill, + selected: microphoneState == MicrophoneState.unmuted, + ), + ]; + }, + buttonBuilder: (context, showMenu) { + return CupertinoButton( + onPressed: showMenu, + child: Icon( + (() { + switch (microphoneState) { + case MicrophoneState.muted: + return CupertinoIcons.mic_slash_fill; + case MicrophoneState.unmuted: + return CupertinoIcons.mic_fill; + } + })(), + ), + ); + }, + ); + } +} + +class _SpeakerButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final speakerState = context.select( + (ActiveCallViewModel value) => value.call.speaker, + ); + + return PullDownButton( + itemBuilder: (context) { + return [ + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.call.speaker = SpeakerState.muted; + }, + title: "Muted", + icon: CupertinoIcons.speaker_slash_fill, + selected: speakerState == SpeakerState.muted, + ), + if (!kIsWeb) + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.call.speaker = SpeakerState.earphone; + }, + title: "Earphone", + icon: CupertinoIcons.ear, + selected: speakerState == SpeakerState.earphone, + ), + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.call.speaker = SpeakerState.speaker; + }, + title: "Speaker", + icon: CupertinoIcons.speaker_2_fill, + selected: speakerState == SpeakerState.speaker, + ), + ]; + }, + buttonBuilder: (context, showMenu) { + return CupertinoButton( + onPressed: showMenu, + child: Icon( + (() { + switch (speakerState) { + case SpeakerState.muted: + return CupertinoIcons.speaker_slash_fill; + case SpeakerState.earphone: + return CupertinoIcons.ear; + case SpeakerState.speaker: + return CupertinoIcons.speaker_2_fill; + } + })(), + ), + ); + }, + ); + } +} diff --git a/lib/ui/views/call_view.dart b/lib/ui/views/call_view.dart deleted file mode 100644 index 478a712..0000000 --- a/lib/ui/views/call_view.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'package:dieklingel_app/blocs/call_view_bloc.dart'; -import 'package:dieklingel_app/components/icon_builder.dart'; -import 'package:dieklingel_app/components/map_builder.dart'; -import 'package:dieklingel_app/states/call_state.dart'; -import 'package:dieklingel_app/ui/view_models/call_view_model.dart'; -import 'package:dieklingel_app/utils/microphone_state.dart'; -import 'package:dieklingel_app/utils/speaker_state.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; - -class CallView extends StatelessWidget { - const CallView({super.key}); - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - _Video(), - SafeArea( - child: Stack( - children: [ - _Toolbar(), - ], - ), - ), - ], - ); - } -} - -class _Toolbar extends StatelessWidget { - @override - Widget build(BuildContext context) { - final isInCall = context.select( - (value) => value.isInCall, - ); - - return Align( - alignment: Alignment.bottomCenter, - child: BlocBuilder( - builder: (context, state) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.max, - children: [ - _ToolbarButton( - icon: const Icon( - CupertinoIcons.phone_fill, - color: Colors.white, - size: 30, - ), - color: isInCall ? Colors.red : Colors.green, - onPressed: () { - if (isInCall) { - context.read().hangup(); - } else { - context.read().dial(); - } - }, - ), - _ToolbarButton( - icon: IconBuilder( - values: { - MicrophoneState.muted: - const Icon(CupertinoIcons.mic_slash_fill), - MicrophoneState.unmuted: const Icon(CupertinoIcons.mic_fill) - }, - fallback: const Icon(CupertinoIcons.mic_slash_fill), - id: state is CallActiveState ? state.microphoneState : null, - ).build( - color: Colors.white, - size: 30, - ), - color: MapBuilder( - values: { - MicrophoneState.muted: Colors.green, - MicrophoneState.unmuted: Colors.red, - }, - fallback: Colors.green, - id: state is CallActiveState ? state.microphoneState : null, - ).build(), - onPressed: state is CallActiveState - ? () { - context - .read() - .add(CallToogleMicrophone()); - } - : null, - ), - _ToolbarButton( - icon: IconBuilder( - values: { - SpeakerState.muted: - const Icon(CupertinoIcons.speaker_slash_fill), - SpeakerState.headphone: - const Icon(CupertinoIcons.speaker_1_fill), - SpeakerState.speaker: - const Icon(CupertinoIcons.speaker_3_fill), - }, - fallback: const Icon(CupertinoIcons.speaker_slash_fill), - id: state is CallActiveState ? state.speakerState : null, - ).build( - color: Colors.white, - size: 30, - ), - color: MapBuilder( - values: { - SpeakerState.muted: Colors.green, - SpeakerState.headphone: Colors.orange, - SpeakerState.speaker: Colors.red, - }, - fallback: Colors.green, - id: state is CallActiveState ? state.speakerState : null, - ).build(), - onPressed: state is CallActiveState - ? () { - context.read().add(CallToogleSpeaker()); - } - : null, - ), - _ToolbarButton( - icon: const Icon( - CupertinoIcons.lock_fill, - color: Colors.white, - size: 30, - ), - color: Colors.amber, - onPressed: () { - //TODO: send unlock trigger - showCupertinoDialog( - context: context, - builder: (BuildContext context) { - Future.delayed(const Duration(milliseconds: 600), () { - Navigator.of(context).pop(); - }); - - return Center( - child: Icon( - CupertinoIcons.lock_open_fill, - size: 150, - color: Colors.green.shade400, - ), - ); - }, - ); - }, - ), - ], - ); - }, - ), - ); - } -} - -class _ToolbarButton extends StatelessWidget { - final Icon icon; - final Color color; - final void Function()? onPressed; - - const _ToolbarButton({ - required this.icon, - required this.color, - this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return CupertinoButton( - onPressed: onPressed, - child: Container( - padding: const EdgeInsets.all(15.0), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: onPressed == null ? Colors.black26 : color, - ), - child: icon, - ), - ); - } -} - -class _Video extends StatelessWidget { - @override - Widget build(BuildContext context) { - final renderer = context.select( - (value) => value.renderer, - ); - - if (renderer == null) { - return Container(); - } - - final videoAvailable = context.select( - (value) => - value.renderer?.srcObject?.getVideoTracks().isNotEmpty ?? false, - ); - - if (!videoAvailable) { - return const Center( - child: CupertinoActivityIndicator(), - ); - } - - return ValueListenableBuilder( - valueListenable: renderer, - builder: (c, v, w) => InteractiveViewer( - child: RTCVideoView( - renderer, - ), - ), - ); - } -} diff --git a/lib/ui/views/core_view.dart b/lib/ui/views/core_view.dart deleted file mode 100644 index 15c3a27..0000000 --- a/lib/ui/views/core_view.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:dieklingel_app/models/hive_home.dart'; -import 'package:dieklingel_app/repositories/ice_server_repository.dart'; -import 'package:dieklingel_app/ui/view_models/call_view_model.dart'; -import 'package:dieklingel_app/ui/view_models/core_view_model.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:mqtt/mqtt.dart'; -import 'package:provider/provider.dart'; - -import 'call_view.dart'; - -class CoreView extends StatelessWidget { - const CoreView({super.key}); - - @override - Widget build(BuildContext context) { - final connectionErrorMessage = context.select( - (value) => value.connectionErrorMessage, - ); - if (connectionErrorMessage != null) { - return Center( - child: Text(connectionErrorMessage), - ); - } - - final isConnected = context.select( - (value) => value.isConnected, - ); - if (!isConnected) { - return const Center( - child: CupertinoActivityIndicator(), - ); - } - - final client = context.select( - (value) => value.client, - ); - final home = context.select( - (value) => value.home, - ); - - return ChangeNotifierProvider( - create: (_) => CallViewModel( - home, - client, - context.read(), - ), - child: const CallView(), - ); - } -} diff --git a/lib/ui/views/home_view.dart b/lib/ui/views/home_view.dart index 62ad1b4..2a7d50a 100644 --- a/lib/ui/views/home_view.dart +++ b/lib/ui/views/home_view.dart @@ -1,6 +1,7 @@ import 'package:dieklingel_app/components/core_home_widget.dart'; import 'package:dieklingel_app/components/fade_page_route.dart'; import 'package:dieklingel_app/models/home.dart'; +import 'package:dieklingel_app/repositories/ice_server_repository.dart'; import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; import 'package:dieklingel_app/ui/view_models/outgoing_call_view_model.dart'; import 'package:dieklingel_app/ui/views/outgoing_call_view.dart'; @@ -119,6 +120,7 @@ class _Content extends StatelessWidget { FadePageRoute( builder: (context) => ChangeNotifierProvider( create: (context) => OutgoingCallViewModel( + iceServerRepository: context.read(), home: home, connection: client, ), diff --git a/lib/ui/views/outgoing_call_view.dart b/lib/ui/views/outgoing_call_view.dart index e92b955..a42eaec 100644 --- a/lib/ui/views/outgoing_call_view.dart +++ b/lib/ui/views/outgoing_call_view.dart @@ -1,11 +1,61 @@ +import 'package:dieklingel_app/ui/view_models/active_call_view_model.dart'; import 'package:dieklingel_app/ui/view_models/outgoing_call_view_model.dart'; +import 'package:dieklingel_app/ui/views/active_call_view.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:mqtt/mqtt.dart'; import 'package:provider/provider.dart'; -class OutgoingCallView extends StatelessWidget { +import '../../components/fade_page_route.dart'; +import '../../models/home.dart'; + +class OutgoingCallView extends StatefulWidget { const OutgoingCallView({super.key}); + @override + State createState() => _OutgoingCallViewState(); +} + +class _OutgoingCallViewState extends State { + @override + void initState() { + super.initState(); + + final Home home = context.read().home; + final Client connection = context.read().connection; + + context.read().onAnswer().then( + (event) { + final (call, remoteSessionId) = event; + + Navigator.pushReplacement( + context, + FadePageRoute( + builder: (context) { + return ChangeNotifierProvider( + create: (context) => ActiveCallViewModel( + home: home, + connection: connection, + call: call, + remoteSessionId: remoteSessionId, + ), + child: const ActiveCallView(), + ); + }, + ), + ); + }, + ); + + context.read().onHangup().then( + (_) { + Navigator.pop(context); + }, + ); + + context.read().call(); + } + @override Widget build(BuildContext context) { final callee = context.select( @@ -34,17 +84,20 @@ class OutgoingCallView extends StatelessWidget { ), ], ), - CupertinoButton( - color: Colors.red, - padding: EdgeInsets.zero, - minSize: kMinInteractiveDimensionCupertino * 1.2, - borderRadius: BorderRadius.circular(999), - child: const Icon( - CupertinoIcons.xmark, + Hero( + tag: "call_hangup_button", + child: CupertinoButton( + color: Colors.red, + padding: EdgeInsets.zero, + minSize: kMinInteractiveDimensionCupertino * 1.2, + borderRadius: BorderRadius.circular(999), + child: const Icon( + CupertinoIcons.xmark, + ), + onPressed: () { + context.read().hangup(); + }, ), - onPressed: () { - Navigator.pop(context); - }, ) ], ), diff --git a/packages/mqtt/lib/src/client.dart b/packages/mqtt/lib/src/client.dart index a4ece24..ce15031 100644 --- a/packages/mqtt/lib/src/client.dart +++ b/packages/mqtt/lib/src/client.dart @@ -56,6 +56,12 @@ class Client { String password = "", bool throws = true, }) async { + if (state == ConnectionState.connected) { + _client.disconnect(); + onConnectionStateChanged?.call(state); + await Future.delayed(const Duration(milliseconds: 50)); + } + try { await _client.connect(username, password); } catch (e) { diff --git a/pubspec.lock b/pubspec.lock index 25fdd44..17b1434 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -511,6 +511,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" + url: "https://pub.dev" + source: hosted + version: "11.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: f9fddd3b46109bd69ff3f9efa5006d2d309b7aec0f3c1c5637a60a2d5659e76e + url: "https://pub.dev" + source: hosted + version: "11.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + url: "https://pub.dev" + source: hosted + version: "9.1.4" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4" + url: "https://pub.dev" + source: hosted + version: "3.12.0" + permission_handler_web: + dependency: "direct main" + description: + name: permission_handler_web + sha256: "78255957b505ae852b51d8894655f826ee48ad4b805c9552d8035a93b3ea9247" + url: "https://pub.dev" + source: hosted + version: "0.0.2" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + url: "https://pub.dev" + source: hosted + version: "0.1.3" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 43008a7..1119cf6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: provider: ^6.0.5 blueprint: ^0.0.3 rxdart: ^0.27.7 + permission_handler: ^11.0.1 + permission_handler_web: ^0.0.2 dev_dependencies: flutter_test: From 85acca8cf34c809cc646e64fc9a79baad22b230e Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Tue, 21 Nov 2023 18:29:40 +0100 Subject: [PATCH 14/35] wip: move views --- .../active/call_active_view.dart} | 32 ++++++++--------- .../active/call_active_view_model.dart} | 12 +++---- .../outgoing/call_outgoing_view.dart} | 36 +++++++++---------- .../outgoing/call_outgoing_view_model.dart} | 22 ++++++------ lib/ui/{views => home}/home_view.dart | 10 +++--- .../home_view_model.dart | 0 6 files changed, 56 insertions(+), 56 deletions(-) rename lib/ui/{views/active_call_view.dart => call/active/call_active_view.dart} (83%) rename lib/ui/{view_models/active_call_view_model.dart => call/active/call_active_view_model.dart} (90%) rename lib/ui/{views/outgoing_call_view.dart => call/outgoing/call_outgoing_view.dart} (68%) rename lib/ui/{view_models/outgoing_call_view_model.dart => call/outgoing/call_outgoing_view_model.dart} (86%) rename lib/ui/{views => home}/home_view.dart (93%) rename lib/ui/{view_models => home}/home_view_model.dart (100%) diff --git a/lib/ui/views/active_call_view.dart b/lib/ui/call/active/call_active_view.dart similarity index 83% rename from lib/ui/views/active_call_view.dart rename to lib/ui/call/active/call_active_view.dart index d30dfbc..2bbf223 100644 --- a/lib/ui/views/active_call_view.dart +++ b/lib/ui/call/active/call_active_view.dart @@ -1,4 +1,4 @@ -import 'package:dieklingel_app/ui/view_models/active_call_view_model.dart'; +import 'package:dieklingel_app/ui/call/active/call_active_view_model.dart'; import 'package:dieklingel_app/utils/microphone_state.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -7,23 +7,23 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:provider/provider.dart'; import 'package:pull_down_button/pull_down_button.dart'; -import '../../models/audio/speaker_state.dart'; +import '../../../models/audio/speaker_state.dart'; -class ActiveCallView extends StatefulWidget { - const ActiveCallView({super.key}); +class CallActiveView extends StatefulWidget { + const CallActiveView({super.key}); @override - State createState() => _ActiveCallViewState(); + State createState() => _CallActiveViewState(); } -class _ActiveCallViewState extends State { +class _CallActiveViewState extends State { bool isEarphone = false; @override void initState() { super.initState(); - context.read().onHangup().then((_) { + context.read().onHangup().then((_) { Navigator.pop(context); }); } @@ -31,7 +31,7 @@ class _ActiveCallViewState extends State { @override Widget build(BuildContext context) { final renderer = context.select( - (ActiveCallViewModel value) => value.call.renderer, + (CallActiveViewModel value) => value.call.renderer, ); return CupertinoPageScaffold( @@ -58,7 +58,7 @@ class _ActiveCallViewState extends State { padding: EdgeInsets.zero, borderRadius: BorderRadius.circular(999), onPressed: () { - context.read().hangup(); + context.read().hangup(); }, child: const Icon(CupertinoIcons.xmark), ), @@ -76,7 +76,7 @@ class _MicrophoneButton extends StatelessWidget { @override Widget build(BuildContext context) { final microphoneState = context.select( - (ActiveCallViewModel value) => value.call.microphone, + (CallActiveViewModel value) => value.call.microphone, ); return PullDownButton( @@ -84,7 +84,7 @@ class _MicrophoneButton extends StatelessWidget { return [ PullDownMenuItem.selectable( onTap: () { - final vm = context.read(); + final vm = context.read(); vm.call.microphone = MicrophoneState.muted; }, title: "Muted", @@ -93,7 +93,7 @@ class _MicrophoneButton extends StatelessWidget { ), PullDownMenuItem.selectable( onTap: () { - final vm = context.read(); + final vm = context.read(); vm.call.microphone = MicrophoneState.unmuted; }, title: "Unmuted", @@ -125,7 +125,7 @@ class _SpeakerButton extends StatelessWidget { @override Widget build(BuildContext context) { final speakerState = context.select( - (ActiveCallViewModel value) => value.call.speaker, + (CallActiveViewModel value) => value.call.speaker, ); return PullDownButton( @@ -133,7 +133,7 @@ class _SpeakerButton extends StatelessWidget { return [ PullDownMenuItem.selectable( onTap: () { - final vm = context.read(); + final vm = context.read(); vm.call.speaker = SpeakerState.muted; }, title: "Muted", @@ -143,7 +143,7 @@ class _SpeakerButton extends StatelessWidget { if (!kIsWeb) PullDownMenuItem.selectable( onTap: () { - final vm = context.read(); + final vm = context.read(); vm.call.speaker = SpeakerState.earphone; }, title: "Earphone", @@ -152,7 +152,7 @@ class _SpeakerButton extends StatelessWidget { ), PullDownMenuItem.selectable( onTap: () { - final vm = context.read(); + final vm = context.read(); vm.call.speaker = SpeakerState.speaker; }, title: "Speaker", diff --git a/lib/ui/view_models/active_call_view_model.dart b/lib/ui/call/active/call_active_view_model.dart similarity index 90% rename from lib/ui/view_models/active_call_view_model.dart rename to lib/ui/call/active/call_active_view_model.dart index a6a36ae..a006bdb 100644 --- a/lib/ui/view_models/active_call_view_model.dart +++ b/lib/ui/call/active/call_active_view_model.dart @@ -7,19 +7,19 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:mqtt/mqtt.dart' as mqtt; import 'package:path/path.dart'; -import '../../handlers/call.dart'; -import '../../models/home.dart'; -import '../../models/messages/candidate_message.dart'; -import '../../models/messages/close_message.dart'; +import '../../../handlers/call.dart'; +import '../../../models/home.dart'; +import '../../../models/messages/candidate_message.dart'; +import '../../../models/messages/close_message.dart'; -class ActiveCallViewModel extends ChangeNotifier { +class CallActiveViewModel extends ChangeNotifier { final Home home; final mqtt.Client connection; final Call call; final Completer _onHangup = Completer(); final String remoteSessionId; - ActiveCallViewModel({ + CallActiveViewModel({ required this.home, required this.connection, required this.call, diff --git a/lib/ui/views/outgoing_call_view.dart b/lib/ui/call/outgoing/call_outgoing_view.dart similarity index 68% rename from lib/ui/views/outgoing_call_view.dart rename to lib/ui/call/outgoing/call_outgoing_view.dart index a42eaec..90cbe79 100644 --- a/lib/ui/views/outgoing_call_view.dart +++ b/lib/ui/call/outgoing/call_outgoing_view.dart @@ -1,30 +1,30 @@ -import 'package:dieklingel_app/ui/view_models/active_call_view_model.dart'; -import 'package:dieklingel_app/ui/view_models/outgoing_call_view_model.dart'; -import 'package:dieklingel_app/ui/views/active_call_view.dart'; +import 'package:dieklingel_app/ui/call/active/call_active_view_model.dart'; +import 'package:dieklingel_app/ui/call/outgoing/call_outgoing_view_model.dart'; +import 'package:dieklingel_app/ui/call/active/call_active_view.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:mqtt/mqtt.dart'; import 'package:provider/provider.dart'; -import '../../components/fade_page_route.dart'; -import '../../models/home.dart'; +import '../../../components/fade_page_route.dart'; +import '../../../models/home.dart'; -class OutgoingCallView extends StatefulWidget { - const OutgoingCallView({super.key}); +class CallOutgoingView extends StatefulWidget { + const CallOutgoingView({super.key}); @override - State createState() => _OutgoingCallViewState(); + State createState() => _CallOutgoingViewState(); } -class _OutgoingCallViewState extends State { +class _CallOutgoingViewState extends State { @override void initState() { super.initState(); - final Home home = context.read().home; - final Client connection = context.read().connection; + final Home home = context.read().home; + final Client connection = context.read().connection; - context.read().onAnswer().then( + context.read().onAnswer().then( (event) { final (call, remoteSessionId) = event; @@ -33,13 +33,13 @@ class _OutgoingCallViewState extends State { FadePageRoute( builder: (context) { return ChangeNotifierProvider( - create: (context) => ActiveCallViewModel( + create: (context) => CallActiveViewModel( home: home, connection: connection, call: call, remoteSessionId: remoteSessionId, ), - child: const ActiveCallView(), + child: const CallActiveView(), ); }, ), @@ -47,18 +47,18 @@ class _OutgoingCallViewState extends State { }, ); - context.read().onHangup().then( + context.read().onHangup().then( (_) { Navigator.pop(context); }, ); - context.read().call(); + context.read().call(); } @override Widget build(BuildContext context) { - final callee = context.select( + final callee = context.select( (value) => value.home.name, ); @@ -95,7 +95,7 @@ class _OutgoingCallViewState extends State { CupertinoIcons.xmark, ), onPressed: () { - context.read().hangup(); + context.read().hangup(); }, ), ) diff --git a/lib/ui/view_models/outgoing_call_view_model.dart b/lib/ui/call/outgoing/call_outgoing_view_model.dart similarity index 86% rename from lib/ui/view_models/outgoing_call_view_model.dart rename to lib/ui/call/outgoing/call_outgoing_view_model.dart index c818d5e..8890c2e 100644 --- a/lib/ui/view_models/outgoing_call_view_model.dart +++ b/lib/ui/call/outgoing/call_outgoing_view_model.dart @@ -8,16 +8,16 @@ import 'package:mqtt/mqtt.dart' as mqtt; import 'package:path/path.dart'; import 'package:uuid/uuid.dart'; -import '../../handlers/call.dart'; -import '../../models/home.dart'; -import '../../models/messages/answer_message.dart'; -import '../../models/messages/candidate_message.dart'; -import '../../models/messages/close_message.dart'; -import '../../models/messages/message_header.dart'; -import '../../models/messages/offer_message.dart'; -import '../../models/messages/session_message_body.dart'; - -class OutgoingCallViewModel extends ChangeNotifier { +import '../../../handlers/call.dart'; +import '../../../models/home.dart'; +import '../../../models/messages/answer_message.dart'; +import '../../../models/messages/candidate_message.dart'; +import '../../../models/messages/close_message.dart'; +import '../../../models/messages/message_header.dart'; +import '../../../models/messages/offer_message.dart'; +import '../../../models/messages/session_message_body.dart'; + +class CallOutgoingViewModel extends ChangeNotifier { final IceServerRepository iceServerRepository; final Home home; final mqtt.Client connection; @@ -27,7 +27,7 @@ class OutgoingCallViewModel extends ChangeNotifier { late final Call _call = Call(const Uuid().v4(), iceServerRepository.servers); - OutgoingCallViewModel({ + CallOutgoingViewModel({ required this.home, required this.connection, required this.iceServerRepository, diff --git a/lib/ui/views/home_view.dart b/lib/ui/home/home_view.dart similarity index 93% rename from lib/ui/views/home_view.dart rename to lib/ui/home/home_view.dart index 2a7d50a..2a647c4 100644 --- a/lib/ui/views/home_view.dart +++ b/lib/ui/home/home_view.dart @@ -2,9 +2,9 @@ import 'package:dieklingel_app/components/core_home_widget.dart'; import 'package:dieklingel_app/components/fade_page_route.dart'; import 'package:dieklingel_app/models/home.dart'; import 'package:dieklingel_app/repositories/ice_server_repository.dart'; -import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; -import 'package:dieklingel_app/ui/view_models/outgoing_call_view_model.dart'; -import 'package:dieklingel_app/ui/views/outgoing_call_view.dart'; +import 'package:dieklingel_app/ui/home/home_view_model.dart'; +import 'package:dieklingel_app/ui/call/outgoing/call_outgoing_view_model.dart'; +import 'package:dieklingel_app/ui/call/outgoing/call_outgoing_view.dart'; import 'package:dieklingel_app/views/home_add_view.dart'; import 'package:dieklingel_app/views/ice_server_add_view.dart'; import 'package:dieklingel_app/views/settings_view.dart'; @@ -119,12 +119,12 @@ class _Content extends StatelessWidget { Navigator.of(context).push( FadePageRoute( builder: (context) => ChangeNotifierProvider( - create: (context) => OutgoingCallViewModel( + create: (context) => CallOutgoingViewModel( iceServerRepository: context.read(), home: home, connection: client, ), - child: const OutgoingCallView(), + child: const CallOutgoingView(), ), ), ); diff --git a/lib/ui/view_models/home_view_model.dart b/lib/ui/home/home_view_model.dart similarity index 100% rename from lib/ui/view_models/home_view_model.dart rename to lib/ui/home/home_view_model.dart From 9000569756f8b9e692d1dc15504afb39f0a7fa89 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Tue, 21 Nov 2023 18:35:56 +0100 Subject: [PATCH 15/35] wip: convert to stateless widget --- lib/main.dart | 4 ++-- lib/ui/call/active/call_active_view.dart | 20 +++++--------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 5c5cc27..efd2c12 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,11 @@ import 'package:dieklingel_app/blocs/home_add_view_bloc.dart'; -import 'package:dieklingel_app/ui/view_models/home_view_model.dart'; +import 'package:dieklingel_app/ui/home/home_view_model.dart'; import 'package:dieklingel_app/handlers/notification_handler.dart'; import 'package:dieklingel_app/models/device.dart'; import 'package:dieklingel_app/models/request.dart'; import 'package:dieklingel_app/repositories/home_repository.dart'; import 'package:dieklingel_app/repositories/ice_server_repository.dart'; -import 'package:dieklingel_app/ui/views/home_view.dart'; +import 'package:dieklingel_app/ui/home/home_view.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:mqtt/mqtt.dart'; import 'package:path/path.dart' as path; diff --git a/lib/ui/call/active/call_active_view.dart b/lib/ui/call/active/call_active_view.dart index 2bbf223..c4e2a40 100644 --- a/lib/ui/call/active/call_active_view.dart +++ b/lib/ui/call/active/call_active_view.dart @@ -9,27 +9,17 @@ import 'package:pull_down_button/pull_down_button.dart'; import '../../../models/audio/speaker_state.dart'; -class CallActiveView extends StatefulWidget { +class CallActiveView extends StatelessWidget { const CallActiveView({super.key}); @override - State createState() => _CallActiveViewState(); -} - -class _CallActiveViewState extends State { - bool isEarphone = false; - - @override - void initState() { - super.initState(); - + Widget build(BuildContext context) { context.read().onHangup().then((_) { - Navigator.pop(context); + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.pop(context); + }); }); - } - @override - Widget build(BuildContext context) { final renderer = context.select( (CallActiveViewModel value) => value.call.renderer, ); From 7291d24910793cc7a3f556fc7362ca72d26bf5fd Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Tue, 21 Nov 2023 18:52:09 +0100 Subject: [PATCH 16/35] feat: move buttons to widgets subfolder --- lib/handlers/call.dart | 36 +++-- lib/ui/call/active/call_active_view.dart | 124 +----------------- .../call/active/call_active_view_model.dart | 26 ++-- .../active/widgets/microphone_button.dart | 57 ++++++++ .../call/active/widgets/speaker_button.dart | 70 ++++++++++ 5 files changed, 172 insertions(+), 141 deletions(-) create mode 100644 lib/ui/call/active/widgets/microphone_button.dart create mode 100644 lib/ui/call/active/widgets/speaker_button.dart diff --git a/lib/handlers/call.dart b/lib/handlers/call.dart index 5bc8a13..9d7ab69 100644 --- a/lib/handlers/call.dart +++ b/lib/handlers/call.dart @@ -1,22 +1,21 @@ import 'dart:async'; -import 'package:dieklingel_app/utils/microphone_state.dart'; import 'package:flutter/foundation.dart'; +import '../models/audio/microphone_state.dart'; import '../models/audio/speaker_state.dart'; import '../models/ice_server.dart'; import 'package:flutter/material.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; -class Call extends ChangeNotifier { +class Call { final String id; final List iceServers; - final RTCVideoRenderer renderer = RTCVideoRenderer(); - final StreamController _localIceCandidates = - StreamController(); - final StreamController _remoteIceCandidates = - StreamController(); + final renderer = RTCVideoRenderer(); + final _localIceCandidates = StreamController(); + final _remoteIceCandidates = StreamController(); + final _connectionState = StreamController(); RTCPeerConnection? connection; SpeakerState _speaker = SpeakerState.muted; @@ -70,7 +69,6 @@ class Call extends ChangeNotifier { set speaker(SpeakerState state) { _speaker = state; - notifyListeners(); for (final track in remoteAudioTracks) { switch (_speaker) { case SpeakerState.muted: @@ -98,7 +96,6 @@ class Call extends ChangeNotifier { set microphone(MicrophoneState state) { _microphone = state; - notifyListeners(); for (final track in loaclAudioTracks) { switch (_microphone) { case MicrophoneState.muted: @@ -129,14 +126,29 @@ class Call extends ChangeNotifier { } for (final track in event.streams.first.getAudioTracks()) { - // TODO:set speaker - // track.enabled = !_isSpeakerMuted; + switch (_speaker) { + case SpeakerState.muted: + track.enabled = false; + break; + case SpeakerState.earphone: + track.enabled = true; + if (!kIsWeb) { + track.enableSpeakerphone(true); + } + break; + case SpeakerState.speaker: + track.enabled = true; + if (!kIsWeb) { + track.enableSpeakerphone(false); + } + break; + } } renderer.srcObject = event.streams.first; } ..onConnectionState = (state) { - notifyListeners(); + _connectionState.add(state); }; await connection!.addTransceiver( diff --git a/lib/ui/call/active/call_active_view.dart b/lib/ui/call/active/call_active_view.dart index c4e2a40..92dfbe4 100644 --- a/lib/ui/call/active/call_active_view.dart +++ b/lib/ui/call/active/call_active_view.dart @@ -1,13 +1,11 @@ -import 'package:dieklingel_app/ui/call/active/call_active_view_model.dart'; -import 'package:dieklingel_app/utils/microphone_state.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:provider/provider.dart'; -import 'package:pull_down_button/pull_down_button.dart'; -import '../../../models/audio/speaker_state.dart'; +import 'call_active_view_model.dart'; +import 'widgets/microphone_button.dart'; +import 'widgets/speaker_button.dart'; class CallActiveView extends StatelessWidget { const CallActiveView({super.key}); @@ -21,7 +19,7 @@ class CallActiveView extends StatelessWidget { }); final renderer = context.select( - (CallActiveViewModel value) => value.call.renderer, + (CallActiveViewModel vm) => vm.renderer, ); return CupertinoPageScaffold( @@ -35,8 +33,8 @@ class CallActiveView extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _MicrophoneButton(), - _SpeakerButton(), + const MicrophoneButton(), + const SpeakerButton(), const CupertinoButton( onPressed: null, child: Icon(CupertinoIcons.lock_fill), @@ -61,113 +59,3 @@ class CallActiveView extends StatelessWidget { ); } } - -class _MicrophoneButton extends StatelessWidget { - @override - Widget build(BuildContext context) { - final microphoneState = context.select( - (CallActiveViewModel value) => value.call.microphone, - ); - - return PullDownButton( - itemBuilder: (context) { - return [ - PullDownMenuItem.selectable( - onTap: () { - final vm = context.read(); - vm.call.microphone = MicrophoneState.muted; - }, - title: "Muted", - icon: CupertinoIcons.mic_slash_fill, - selected: microphoneState == MicrophoneState.muted, - ), - PullDownMenuItem.selectable( - onTap: () { - final vm = context.read(); - vm.call.microphone = MicrophoneState.unmuted; - }, - title: "Unmuted", - icon: CupertinoIcons.mic_fill, - selected: microphoneState == MicrophoneState.unmuted, - ), - ]; - }, - buttonBuilder: (context, showMenu) { - return CupertinoButton( - onPressed: showMenu, - child: Icon( - (() { - switch (microphoneState) { - case MicrophoneState.muted: - return CupertinoIcons.mic_slash_fill; - case MicrophoneState.unmuted: - return CupertinoIcons.mic_fill; - } - })(), - ), - ); - }, - ); - } -} - -class _SpeakerButton extends StatelessWidget { - @override - Widget build(BuildContext context) { - final speakerState = context.select( - (CallActiveViewModel value) => value.call.speaker, - ); - - return PullDownButton( - itemBuilder: (context) { - return [ - PullDownMenuItem.selectable( - onTap: () { - final vm = context.read(); - vm.call.speaker = SpeakerState.muted; - }, - title: "Muted", - icon: CupertinoIcons.speaker_slash_fill, - selected: speakerState == SpeakerState.muted, - ), - if (!kIsWeb) - PullDownMenuItem.selectable( - onTap: () { - final vm = context.read(); - vm.call.speaker = SpeakerState.earphone; - }, - title: "Earphone", - icon: CupertinoIcons.ear, - selected: speakerState == SpeakerState.earphone, - ), - PullDownMenuItem.selectable( - onTap: () { - final vm = context.read(); - vm.call.speaker = SpeakerState.speaker; - }, - title: "Speaker", - icon: CupertinoIcons.speaker_2_fill, - selected: speakerState == SpeakerState.speaker, - ), - ]; - }, - buttonBuilder: (context, showMenu) { - return CupertinoButton( - onPressed: showMenu, - child: Icon( - (() { - switch (speakerState) { - case SpeakerState.muted: - return CupertinoIcons.speaker_slash_fill; - case SpeakerState.earphone: - return CupertinoIcons.ear; - case SpeakerState.speaker: - return CupertinoIcons.speaker_2_fill; - } - })(), - ), - ); - }, - ); - } -} diff --git a/lib/ui/call/active/call_active_view_model.dart b/lib/ui/call/active/call_active_view_model.dart index a006bdb..7e61d29 100644 --- a/lib/ui/call/active/call_active_view_model.dart +++ b/lib/ui/call/active/call_active_view_model.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; +import 'package:dieklingel_app/models/audio/speaker_state.dart'; import 'package:dieklingel_app/models/messages/session_message_header.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -8,6 +9,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:mqtt/mqtt.dart' as mqtt; import 'package:path/path.dart'; import '../../../handlers/call.dart'; +import '../../../models/audio/microphone_state.dart'; import '../../../models/home.dart'; import '../../../models/messages/candidate_message.dart'; import '../../../models/messages/close_message.dart'; @@ -25,8 +27,6 @@ class CallActiveViewModel extends ChangeNotifier { required this.call, required this.remoteSessionId, }) { - call.addListener(_onCallChange); - connection.subscribe( "${home.username}/connections/candidate", (topic, message) { @@ -61,21 +61,26 @@ class CallActiveViewModel extends ChangeNotifier { ); } - void _onCallChange() { + set microphone(MicrophoneState state) { + call.microphone = state; notifyListeners(); } - set isMicrophoneMuted(bool value) { - Helper.setMicrophoneMute( - value, - call.renderer.srcObject!.getAudioTracks().first, - ); + MicrophoneState get microphone { + return call.microphone; + } + set speaker(SpeakerState state) { + call.speaker = state; notifyListeners(); } - bool get isMicrophoneMuted { - return false; + SpeakerState get speaker { + return call.speaker; + } + + RTCVideoRenderer get renderer { + return call.renderer; } Future onHangup() async { @@ -83,7 +88,6 @@ class CallActiveViewModel extends ChangeNotifier { } void hangup() { - call.removeListener(_onCallChange); call.close(); final payload = CloseMessage( diff --git a/lib/ui/call/active/widgets/microphone_button.dart b/lib/ui/call/active/widgets/microphone_button.dart new file mode 100644 index 0000000..80269a6 --- /dev/null +++ b/lib/ui/call/active/widgets/microphone_button.dart @@ -0,0 +1,57 @@ +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; +import 'package:pull_down_button/pull_down_button.dart'; + +import '../../../../models/audio/microphone_state.dart'; +import '../call_active_view_model.dart'; + +class MicrophoneButton extends StatelessWidget { + const MicrophoneButton({super.key}); + + @override + Widget build(BuildContext context) { + final microphone = context.select( + (CallActiveViewModel vm) => vm.microphone, + ); + + return PullDownButton( + itemBuilder: (context) { + return [ + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.microphone = MicrophoneState.muted; + }, + title: "Muted", + icon: CupertinoIcons.mic_slash_fill, + selected: microphone == MicrophoneState.muted, + ), + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.microphone = MicrophoneState.unmuted; + }, + title: "Unmuted", + icon: CupertinoIcons.mic_fill, + selected: microphone == MicrophoneState.unmuted, + ), + ]; + }, + buttonBuilder: (context, showMenu) { + return CupertinoButton( + onPressed: showMenu, + child: Icon( + (() { + switch (microphone) { + case MicrophoneState.muted: + return CupertinoIcons.mic_slash_fill; + case MicrophoneState.unmuted: + return CupertinoIcons.mic_fill; + } + })(), + ), + ); + }, + ); + } +} diff --git a/lib/ui/call/active/widgets/speaker_button.dart b/lib/ui/call/active/widgets/speaker_button.dart new file mode 100644 index 0000000..0b65dad --- /dev/null +++ b/lib/ui/call/active/widgets/speaker_button.dart @@ -0,0 +1,70 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:provider/provider.dart'; +import 'package:pull_down_button/pull_down_button.dart'; + +import '../../../../models/audio/speaker_state.dart'; +import '../call_active_view_model.dart'; + +class SpeakerButton extends StatelessWidget { + const SpeakerButton({super.key}); + + @override + Widget build(BuildContext context) { + final speaker = context.select( + (CallActiveViewModel vm) => vm.speaker, + ); + + return PullDownButton( + itemBuilder: (context) { + return [ + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.speaker = SpeakerState.muted; + }, + title: "Muted", + icon: CupertinoIcons.speaker_slash_fill, + selected: speaker == SpeakerState.muted, + ), + if (!kIsWeb) + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.speaker = SpeakerState.earphone; + }, + title: "Earphone", + icon: CupertinoIcons.ear, + selected: speaker == SpeakerState.earphone, + ), + PullDownMenuItem.selectable( + onTap: () { + final vm = context.read(); + vm.speaker = SpeakerState.speaker; + }, + title: "Speaker", + icon: CupertinoIcons.speaker_2_fill, + selected: speaker == SpeakerState.speaker, + ), + ]; + }, + buttonBuilder: (context, showMenu) { + return CupertinoButton( + onPressed: showMenu, + child: Icon( + (() { + switch (speaker) { + case SpeakerState.muted: + return CupertinoIcons.speaker_slash_fill; + case SpeakerState.earphone: + return CupertinoIcons.ear; + case SpeakerState.speaker: + return CupertinoIcons.speaker_2_fill; + } + })(), + ), + ); + }, + ); + } +} From 7c55abf83b6b48eb9fad62febc787c7c003ff066 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Tue, 21 Nov 2023 18:56:06 +0100 Subject: [PATCH 17/35] feat: move to ui subfolder --- .../settings/about}/about_view.dart | 0 lib/{views => ui/settings}/settings_view.dart | 14 +- lib/ui/view_models/call_view_model.dart | 163 ------------------ lib/ui/view_models/core_view_model.dart | 44 ----- 4 files changed, 7 insertions(+), 214 deletions(-) rename lib/{views => ui/settings/about}/about_view.dart (100%) rename lib/{views => ui/settings}/settings_view.dart (88%) delete mode 100644 lib/ui/view_models/call_view_model.dart delete mode 100644 lib/ui/view_models/core_view_model.dart diff --git a/lib/views/about_view.dart b/lib/ui/settings/about/about_view.dart similarity index 100% rename from lib/views/about_view.dart rename to lib/ui/settings/about/about_view.dart diff --git a/lib/views/settings_view.dart b/lib/ui/settings/settings_view.dart similarity index 88% rename from lib/views/settings_view.dart rename to lib/ui/settings/settings_view.dart index 51bc9b1..5ffcf8a 100644 --- a/lib/views/settings_view.dart +++ b/lib/ui/settings/settings_view.dart @@ -1,15 +1,15 @@ -import 'package:dieklingel_app/blocs/home_list_view_bloc.dart'; -import 'package:dieklingel_app/blocs/ice_server_list_view_bloc.dart'; -import 'package:dieklingel_app/repositories/home_repository.dart'; -import 'package:dieklingel_app/repositories/ice_server_repository.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'about_view.dart'; +import '../../blocs/home_list_view_bloc.dart'; +import '../../blocs/ice_server_list_view_bloc.dart'; +import '../../repositories/home_repository.dart'; +import '../../repositories/ice_server_repository.dart'; +import 'about/about_view.dart'; -import '../views/home_list_view.dart'; -import 'ice_server_list_view.dart'; +import '../../views/home_list_view.dart'; +import '../../views/ice_server_list_view.dart'; class SettingsView extends StatelessWidget { const SettingsView({super.key}); diff --git a/lib/ui/view_models/call_view_model.dart b/lib/ui/view_models/call_view_model.dart deleted file mode 100644 index 04311b7..0000000 --- a/lib/ui/view_models/call_view_model.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import 'package:dieklingel_app/handlers/call.dart'; -import 'package:dieklingel_app/handlers/call_kit.dart'; -import 'package:dieklingel_app/models/hive_home.dart'; -import 'package:dieklingel_app/models/messages/answer_message.dart'; -import 'package:dieklingel_app/models/messages/candidate_message.dart'; -import 'package:dieklingel_app/models/messages/candidate_message_body.dart'; -import 'package:dieklingel_app/models/messages/close_message.dart'; -import 'package:dieklingel_app/models/messages/message_header.dart'; -import 'package:dieklingel_app/models/messages/offer_message.dart'; -import 'package:dieklingel_app/models/messages/session_message_body.dart'; -import 'package:dieklingel_app/models/messages/session_message_header.dart'; -import 'package:dieklingel_app/repositories/ice_server_repository.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; -import 'package:mqtt/mqtt.dart'; -import 'package:path/path.dart'; -import 'package:uuid/uuid.dart'; - -class CallViewModel extends ChangeNotifier { - final HiveHome home; - final Client client; - final IceServerRepository iceServerRepository; - - String _remoteSessionId = ""; - String _localSessionId = ""; - - CallViewModel(this.home, this.client, this.iceServerRepository) { - client.subscribe( - "${home.username}/connections/answer", - (topic, message) async { - try { - final payload = AnswerMessage.fromMap(json.decode(message)); - _remoteSessionId = payload.header.senderSessionId; - final call = CallKit.calls[payload.header.sessionId]; - if (call == null) { - return; - } - - await call.withRemoteAnswer(payload.body.sessionDescription); - } catch (e) { - log("could not parse the answer message; message: $message, error: $e"); - } - }, - ); - - client.subscribe( - "${home.username}/connections/candidate", - (topic, message) { - try { - final payload = CandidateMessage.fromMap(json.decode(message)); - final call = CallKit.calls[_localSessionId]; - if (call == null) { - return; - } - - call.remoteIceCandidates.add(payload.body.iceCandidate); - } catch (e) { - log("could not parse the candidate message; message: $message, error: $e"); - } - }, - ); - - client.subscribe( - "${home.username}/connections/close", - (topic, message) async { - try { - final payload = CloseMessage.fromMap(json.decode(message)); - final call = CallKit.calls[payload.header.sessionId]; - if (call == null) { - return; - } - - await call.close(); - CallKit.calls.remove(payload.header.sessionId); - _localSessionId = ""; - _remoteSessionId = ""; - } catch (e) { - log("could not parse the close message; message: $message, error: $e"); - } - }, - ); - } - - bool get isInCall { - return CallKit.calls[_localSessionId] != null; - } - - RTCVideoRenderer? get renderer { - return CallKit.calls[_localSessionId]?.renderer; - } - - void dial() async { - _localSessionId = const Uuid().v4(); - final call = Call(_localSessionId, iceServerRepository.servers); - call.addListener(() { - notifyListeners(); - }); - CallKit.calls[_localSessionId] = call; - notifyListeners(); - - final offer = await call.offer(); - final payload = OfferMessage( - header: MessageHeader( - senderDeviceId: home.username ?? "", - senderSessionId: call.id, - ), - body: SessionMessageBody( - sessionDescription: offer, - ), - ); - - client.publish( - normalize("./${home.uri.path}/connections/offer"), - json.encode(payload.toMap()), - ); - - call.localIceCandidates.listen((candidate) { - final payload = CandidateMessage( - header: SessionMessageHeader( - senderDeviceId: home.username ?? "", - senderSessionId: _localSessionId, - sessionId: _remoteSessionId, - ), - body: CandidateMessageBody( - iceCandidate: candidate, - ), - ); - - client.publish( - normalize("./${home.uri.path}/connections/candidate"), - json.encode(payload.toMap()), - ); - }); - } - - void hangup() async { - final call = CallKit.calls[_localSessionId]; - if (call == null) { - return; - } - final payload = CloseMessage( - header: SessionMessageHeader( - senderDeviceId: home.username ?? "", - senderSessionId: call.id, - sessionId: _remoteSessionId, - ), - ); - - await call.close(); - CallKit.calls.remove(_localSessionId); - _localSessionId = ""; - _remoteSessionId = ""; - notifyListeners(); - - client.publish( - normalize("./${home.uri.path}/connections/close"), - json.encode(payload.toMap()), - ); - } -} diff --git a/lib/ui/view_models/core_view_model.dart b/lib/ui/view_models/core_view_model.dart deleted file mode 100644 index 1ce59a9..0000000 --- a/lib/ui/view_models/core_view_model.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:dieklingel_app/models/hive_home.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:mqtt/mqtt.dart'; -import 'package:mqtt_client/mqtt_client.dart' as mqtt; - -class CoreViewModel extends ChangeNotifier { - final HiveHome home; - final Client client; - - bool _isConnected = false; - String? _connectionErrorMessage; - - CoreViewModel(this.home, this.client) { - connect(); - } - - bool get isConnected { - return _isConnected; - } - - String? get connectionErrorMessage { - return _connectionErrorMessage; - } - - void connect() async { - _isConnected = false; - _connectionErrorMessage = null; - notifyListeners(); - - try { - await client.connect( - username: home.username ?? "", - password: home.password ?? "", - ); - } on mqtt.NoConnectionException catch (exception) { - _connectionErrorMessage = exception.toString(); - notifyListeners(); - return; - } - - _isConnected = true; - notifyListeners(); - } -} From aac0be0aa385f58d42555398922ecb71b62f6f7d Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Tue, 21 Nov 2023 19:09:22 +0100 Subject: [PATCH 18/35] feat: move call --- lib/models/call/call.dart | 208 +++++++++++++++++++++++++++++++++++++ lib/ui/home/home_view.dart | 2 +- 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 lib/models/call/call.dart diff --git a/lib/models/call/call.dart b/lib/models/call/call.dart new file mode 100644 index 0000000..c7ab25c --- /dev/null +++ b/lib/models/call/call.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +import '../../utils/media_ressource.dart'; +import '../audio/microphone_state.dart'; +import '../audio/speaker_state.dart'; +import '../ice_server.dart'; + +class Call { + final String id; + final List iceServers; + final renderer = RTCVideoRenderer(); + final _localIceCandidates = StreamController(); + final _remoteIceCandidates = StreamController(); + final _connectionState = StreamController(); + final _media = MediaRessource(); + + RTCPeerConnection? connection; + SpeakerState _speaker = SpeakerState.muted; + MicrophoneState _microphone = MicrophoneState.muted; + + Call( + this.id, + this.iceServers, + ) { + WidgetsFlutterBinding.ensureInitialized(); + _remoteIceCandidates.stream.listen((event) { + connection!.addCandidate(event); + }); + } + + List get loaclAudioTracks { + final conn = connection; + if (conn == null) { + return []; + } + + final List tracks = conn + .getLocalStreams() + .whereType() + .expand( + (stream) => stream.getAudioTracks(), + ) + .toList(); + return tracks; + } + + List get remoteAudioTracks { + final conn = connection; + if (conn == null) { + return []; + } + + final List tracks = conn + .getRemoteStreams() + .whereType() + .expand( + (stream) => stream.getAudioTracks(), + ) + .toList(); + return tracks; + } + + SpeakerState get speaker { + return _speaker; + } + + set speaker(SpeakerState state) { + _speaker = state; + for (final track in remoteAudioTracks) { + switch (_speaker) { + case SpeakerState.muted: + track.enabled = false; + break; + case SpeakerState.earphone: + track.enabled = true; + if (!kIsWeb) { + track.enableSpeakerphone(true); + } + break; + case SpeakerState.speaker: + track.enabled = true; + if (!kIsWeb) { + track.enableSpeakerphone(false); + } + break; + } + } + } + + MicrophoneState get microphone { + return _microphone; + } + + set microphone(MicrophoneState state) { + _microphone = state; + for (final track in loaclAudioTracks) { + switch (_microphone) { + case MicrophoneState.muted: + track.enabled = false; + break; + case MicrophoneState.unmuted: + track.enabled = true; + break; + } + } + } + + Future offer() async { + await renderer.initialize(); + + connection = await createPeerConnection({ + "iceServers": iceServers.map((e) => e.toMap()).toList(), + "sdpSemantics": "unified-plan", + }); + + connection! + ..onIceCandidate = (candidate) { + _localIceCandidates.add(candidate); + } + ..onTrack = (event) { + if (event.streams.isEmpty) { + return; + } + + for (final track in event.streams.first.getAudioTracks()) { + switch (_speaker) { + case SpeakerState.muted: + track.enabled = false; + break; + case SpeakerState.earphone: + track.enabled = true; + if (!kIsWeb) { + track.enableSpeakerphone(true); + } + break; + case SpeakerState.speaker: + track.enabled = true; + if (!kIsWeb) { + track.enableSpeakerphone(false); + } + break; + } + } + + renderer.srcObject = event.streams.first; + } + ..onConnectionState = (state) { + _connectionState.add(state); + }; + + await connection!.addTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeAudio, + init: RTCRtpTransceiverInit(direction: TransceiverDirection.SendRecv), + ); + await connection!.addTransceiver( + kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, + init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), + ); + MediaStream? stream = await _media.open(true, false); + for (final track in stream?.getAudioTracks() ?? []) { + switch (_microphone) { + case MicrophoneState.muted: + track.enabled = false; + break; + case MicrophoneState.unmuted: + track.enabled = true; + break; + } + } + + final offer = await connection!.createOffer(); + await connection!.setLocalDescription(offer); + return offer; + } + + Future withRemoteAnswer(RTCSessionDescription answer) async { + await connection!.setRemoteDescription(answer); + } + + RTCPeerConnectionState get state { + final connectionState = connection?.connectionState; + if (connectionState == null) { + return RTCPeerConnectionState.RTCPeerConnectionStateClosed; + } + + return connectionState; + } + + Stream get localIceCandidates { + return _localIceCandidates.stream; + } + + Sink get remoteIceCandidates { + return _remoteIceCandidates.sink; + } + + Future close() async { + _media.close(); + _localIceCandidates.close(); + _remoteIceCandidates.close(); + renderer.dispose(); + } +} diff --git a/lib/ui/home/home_view.dart b/lib/ui/home/home_view.dart index 2a647c4..8ffe856 100644 --- a/lib/ui/home/home_view.dart +++ b/lib/ui/home/home_view.dart @@ -7,7 +7,7 @@ import 'package:dieklingel_app/ui/call/outgoing/call_outgoing_view_model.dart'; import 'package:dieklingel_app/ui/call/outgoing/call_outgoing_view.dart'; import 'package:dieklingel_app/views/home_add_view.dart'; import 'package:dieklingel_app/views/ice_server_add_view.dart'; -import 'package:dieklingel_app/views/settings_view.dart'; +import 'package:dieklingel_app/ui/settings/settings_view.dart'; import 'package:flutter/cupertino.dart'; import 'package:mqtt/mqtt.dart' as mqtt; import 'package:provider/provider.dart'; From e4bedea48ef559add4e8f657d307e17bf22e1b76 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Tue, 21 Nov 2023 21:50:26 +0100 Subject: [PATCH 19/35] feat: build with xcode 15 and enable speakerphone --- ios/ExportOptions.plist | 2 - ios/ImageNotification/Info.plist | 17 -- ios/ImageNotification/NotificationService.h | 12 - ios/ImageNotification/NotificationService.m | 38 ---- ios/Podfile | 5 - ios/Podfile.lock | 47 ++-- ios/Runner.xcodeproj/project.pbxproj | 213 +----------------- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- ios/fastlane/Fastfile | 4 +- lib/handlers/call.dart | 194 ---------------- lib/handlers/call_kit.dart | 4 - lib/models/call/call.dart | 33 +-- lib/ui/call/active/call_active_view.dart | 28 ++- .../call/active/call_active_view_model.dart | 12 +- .../active/widgets/microphone_button.dart | 2 + .../outgoing/call_outgoing_view_model.dart | 2 +- pubspec.lock | 70 +++--- pubspec.yaml | 3 - 18 files changed, 104 insertions(+), 584 deletions(-) delete mode 100644 ios/ImageNotification/Info.plist delete mode 100644 ios/ImageNotification/NotificationService.h delete mode 100644 ios/ImageNotification/NotificationService.m delete mode 100644 lib/handlers/call.dart diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist index 190d01c..91ac72b 100644 --- a/ios/ExportOptions.plist +++ b/ios/ExportOptions.plist @@ -12,8 +12,6 @@ com.dieklingel.app match AppStore com.dieklingel.app - com.dieklingel.app.ImageNotification - match AppStore com.dieklingel.app.ImageNotification signingCertificate 8702C3CEE167260D632A1AC65DE99063A5FEC4DF diff --git a/ios/ImageNotification/Info.plist b/ios/ImageNotification/Info.plist deleted file mode 100644 index 6d42710..0000000 --- a/ios/ImageNotification/Info.plist +++ /dev/null @@ -1,17 +0,0 @@ - - - - - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - NSExtension - - NSExtensionPointIdentifier - com.apple.usernotifications.service - NSExtensionPrincipalClass - NotificationService - - - diff --git a/ios/ImageNotification/NotificationService.h b/ios/ImageNotification/NotificationService.h deleted file mode 100644 index 65839bf..0000000 --- a/ios/ImageNotification/NotificationService.h +++ /dev/null @@ -1,12 +0,0 @@ -// -// NotificationService.h -// ImageNotification -// -// Created by Kai Mayer on 02.10.22. -// - -#import - -@interface NotificationService : UNNotificationServiceExtension - -@end diff --git a/ios/ImageNotification/NotificationService.m b/ios/ImageNotification/NotificationService.m deleted file mode 100644 index a906af1..0000000 --- a/ios/ImageNotification/NotificationService.m +++ /dev/null @@ -1,38 +0,0 @@ -// -// NotificationService.m -// ImageNotification -// -// Created by Kai Mayer on 02.10.22. -// - -#import -#import "NotificationService.h" -#import "FirebaseMessaging.h" - -@interface NotificationService () - -@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver); -@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent; - -@end - -@implementation NotificationService - -- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler { - self.contentHandler = contentHandler; - self.bestAttemptContent = [request.content mutableCopy]; - - // Modify the notification content here... - // self.bestAttemptContent.title = [NSString stringWithFormat:@"%@ [modified]", self.bestAttemptContent.title]; - // - // self.contentHandler(self.bestAttemptContent); - [[FIRMessaging extensionHelper] populateNotificationContent:self.bestAttemptContent withContentHandler:contentHandler]; -} - -- (void)serviceExtensionTimeWillExpire { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - self.contentHandler(self.bestAttemptContent); -} - -@end diff --git a/ios/Podfile b/ios/Podfile index 1838b30..10f3c9b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,8 +39,3 @@ post_install do |installer| flutter_additional_ios_build_settings(target) end end - -target 'ImageNotification' do - use_frameworks! - pod 'Firebase/Messaging' -end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b07d84c..1c73c3e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -17,9 +17,9 @@ PODS: - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Logger (~> 7.8) - - FirebaseCoreInternal (10.14.0): + - FirebaseCoreInternal (10.18.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseInstallations (10.14.0): + - FirebaseInstallations (10.18.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) @@ -43,31 +43,33 @@ PODS: - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/AppDelegateSwizzler (7.11.5): + - GoogleUtilities/AppDelegateSwizzler (7.12.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - - GoogleUtilities/Environment (7.11.5): + - GoogleUtilities/Environment (7.12.0): - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.11.5): + - GoogleUtilities/Logger (7.12.0): - GoogleUtilities/Environment - - GoogleUtilities/Network (7.11.5): + - GoogleUtilities/Network (7.12.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.11.5)" - - GoogleUtilities/Reachability (7.11.5): + - "GoogleUtilities/NSData+zlib (7.12.0)" + - GoogleUtilities/Reachability (7.12.0): - GoogleUtilities/Logger - - GoogleUtilities/UserDefaults (7.11.5): + - GoogleUtilities/UserDefaults (7.12.0): - GoogleUtilities/Logger - - nanopb (2.30909.0): - - nanopb/decode (= 2.30909.0) - - nanopb/encode (= 2.30909.0) - - nanopb/decode (2.30909.0) - - nanopb/encode (2.30909.0) + - nanopb (2.30909.1): + - nanopb/decode (= 2.30909.1) + - nanopb/encode (= 2.30909.1) + - nanopb/decode (2.30909.1) + - nanopb/encode (2.30909.1) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - permission_handler_apple (9.1.1): + - Flutter - PromisesObjC (2.3.1) - url_launcher_ios (0.0.1): - Flutter @@ -75,13 +77,13 @@ PODS: DEPENDENCIES: - audio_session (from `.symlinks/plugins/audio_session/ios`) - - Firebase/Messaging - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: @@ -112,6 +114,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_webrtc/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" @@ -121,20 +125,21 @@ SPEC CHECKSUMS: firebase_core: 4a3246a02f828a01c74a2c26427037786d90f17f firebase_messaging: 13b378c8449cae7ec96c79570170943dd73d4738 FirebaseCore: f86a1394906b97ac445ae49c92552a9425831bed - FirebaseCoreInternal: d558159ee6cc4b823c2296ecc193de9f6d9a5bb3 - FirebaseInstallations: f672b1eda64e6381c21d424a2f680a943fd83f3b + FirebaseCoreInternal: 8eb002e564b533bdcf1ba011f33f2b5c10e2ed4a + FirebaseInstallations: e842042ec6ac1fd2e37d7706363ebe7f662afea4 FirebaseMessaging: bb2c4f6422a753038fe137d90ae7c1af57251316 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 flutter_webrtc: 1944895d4e908c4bc722929dc4b9f8620d8e1b2f GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 - GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 - nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 + GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34 + nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 WebRTC-SDK: dd913fd31cfbf1d43b9a22d83f4c6354c960c623 -PODFILE CHECKSUM: 4795d042abc5acd4532a44cf526446489e113bab +PODFILE CHECKSUM: cc1f88378b4bfcf93a6ce00d2c587857c6008d3b -COCOAPODS: 1.12.0 +COCOAPODS: 1.14.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 243ec3d..b815892 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,12 +8,9 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 2AFE261E29EC785400B3D853 /* NotificationService.m in Sources */ = {isa = PBXBuildFile; fileRef = 2AFE261D29EC785400B3D853 /* NotificationService.m */; }; - 2AFE262229EC785400B3D853 /* ImageNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2AFE261A29EC785400B3D853 /* ImageNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 2AFE262C29EC7A7100B3D853 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2AFE262B29EC7A7100B3D853 /* GoogleService-Info.plist */; }; 2AFE263029EC7C3C00B3D853 /* ringtone.wav in Resources */ = {isa = PBXBuildFile; fileRef = 2AFE262F29EC7C3C00B3D853 /* ringtone.wav */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 5ACDD787E3C657C92DBC134C /* Pods_ImageNotification.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B54523D2DDC3F2A5828AB77 /* Pods_ImageNotification.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -21,16 +18,6 @@ FD91F7BD6865A876168AA93E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D3A9FC6A332A3FA53EE4E976 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - 2AFE262029EC785400B3D853 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 2AFE261929EC785400B3D853; - remoteInfo = ImageNotification; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXCopyFilesBuildPhase section */ 2AFE261129EC773F00B3D853 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; @@ -38,7 +25,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 2AFE262229EC785400B3D853 /* ImageNotification.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -61,7 +47,6 @@ 1D3A49B4068FA52AA8D90F9A /* Pods-ImageNotification.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ImageNotification.profile.xcconfig"; path = "Target Support Files/Pods-ImageNotification/Pods-ImageNotification.profile.xcconfig"; sourceTree = ""; }; 2AFE260B29EC773F00B3D853 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 2AFE260D29EC773F00B3D853 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 2AFE261A29EC785400B3D853 /* ImageNotification.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ImageNotification.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 2AFE261C29EC785400B3D853 /* NotificationService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NotificationService.h; sourceTree = ""; }; 2AFE261D29EC785400B3D853 /* NotificationService.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NotificationService.m; sourceTree = ""; }; 2AFE261F29EC785400B3D853 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -91,14 +76,6 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 2AFE261729EC785400B3D853 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 5ACDD787E3C657C92DBC134C /* Pods_ImageNotification.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -160,7 +137,6 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - 2AFE261A29EC785400B3D853 /* ImageNotification.appex */, ); name = Products; sourceTree = ""; @@ -207,24 +183,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 2AFE261929EC785400B3D853 /* ImageNotification */ = { - isa = PBXNativeTarget; - buildConfigurationList = 2AFE262329EC785400B3D853 /* Build configuration list for PBXNativeTarget "ImageNotification" */; - buildPhases = ( - A6FC08691D38778947F34927 /* [CP] Check Pods Manifest.lock */, - 2AFE261629EC785400B3D853 /* Sources */, - 2AFE261729EC785400B3D853 /* Frameworks */, - 2AFE261829EC785400B3D853 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = ImageNotification; - productName = ImageNotification; - productReference = 2AFE261A29EC785400B3D853 /* ImageNotification.appex */; - productType = "com.apple.product-type.app-extension"; - }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; @@ -242,7 +200,6 @@ buildRules = ( ); dependencies = ( - 2AFE262129EC785400B3D853 /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -256,12 +213,9 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1430; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { - 2AFE261929EC785400B3D853 = { - CreatedOnToolsVersion = 14.3; - }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; @@ -282,19 +236,11 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - 2AFE261929EC785400B3D853 /* ImageNotification */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 2AFE261829EC785400B3D853 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -381,39 +327,9 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - A6FC08691D38778947F34927 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-ImageNotification-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 2AFE261629EC785400B3D853 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 2AFE261E29EC785400B3D853 /* NotificationService.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -425,14 +341,6 @@ }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - 2AFE262129EC785400B3D853 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 2AFE261929EC785400B3D853 /* ImageNotification */; - targetProxy = 2AFE262029EC785400B3D853 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -530,115 +438,6 @@ }; name = Profile; }; - 2AFE262429EC785400B3D853 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 2AFE262D29EC7AEF00B3D853 /* DebugNotification.xcconfig */; - buildSettings = { - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3QLZPMLJ3W; - GCC_C_LANGUAGE_STANDARD = gnu11; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ImageNotification/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = ImageNotification; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.dieklingel.app.ImageNotification; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 2AFE262529EC785400B3D853 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 2AFE262D29EC7AEF00B3D853 /* DebugNotification.xcconfig */; - buildSettings = { - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 3QLZPMLJ3W; - GCC_C_LANGUAGE_STANDARD = gnu11; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ImageNotification/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = ImageNotification; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.dieklingel.app.ImageNotification; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.dieklingel.app.ImageNotification"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 2AFE262629EC785400B3D853 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 2AFE262E29EC7AEF00B3D853 /* ReleaseNotification.xcconfig */; - buildSettings = { - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 3QLZPMLJ3W; - GCC_C_LANGUAGE_STANDARD = gnu11; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ImageNotification/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = ImageNotification; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.4; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.dieklingel.app.ImageNotification; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Profile; - }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -806,16 +605,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 2AFE262329EC785400B3D853 /* Build configuration list for PBXNativeTarget "ImageNotification" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 2AFE262429EC785400B3D853 /* Debug */, - 2AFE262529EC785400B3D853 /* Release */, - 2AFE262629EC785400B3D853 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a..a6b826d 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ iceServers; - final renderer = RTCVideoRenderer(); - final _localIceCandidates = StreamController(); - final _remoteIceCandidates = StreamController(); - final _connectionState = StreamController(); - - RTCPeerConnection? connection; - SpeakerState _speaker = SpeakerState.muted; - MicrophoneState _microphone = MicrophoneState.muted; - - Call( - this.id, - this.iceServers, - ) { - WidgetsFlutterBinding.ensureInitialized(); - _remoteIceCandidates.stream.listen((event) { - connection!.addCandidate(event); - }); - } - - List get loaclAudioTracks { - final conn = connection; - if (conn == null) { - return []; - } - - final List tracks = conn - .getLocalStreams() - .whereType() - .expand( - (stream) => stream.getAudioTracks(), - ) - .toList(); - return tracks; - } - - List get remoteAudioTracks { - final conn = connection; - if (conn == null) { - return []; - } - - final List tracks = conn - .getRemoteStreams() - .whereType() - .expand( - (stream) => stream.getAudioTracks(), - ) - .toList(); - return tracks; - } - - SpeakerState get speaker { - return _speaker; - } - - set speaker(SpeakerState state) { - _speaker = state; - for (final track in remoteAudioTracks) { - switch (_speaker) { - case SpeakerState.muted: - track.enabled = false; - break; - case SpeakerState.earphone: - track.enabled = true; - if (!kIsWeb) { - track.enableSpeakerphone(true); - } - break; - case SpeakerState.speaker: - track.enabled = true; - if (!kIsWeb) { - track.enableSpeakerphone(false); - } - break; - } - } - } - - MicrophoneState get microphone { - return _microphone; - } - - set microphone(MicrophoneState state) { - _microphone = state; - for (final track in loaclAudioTracks) { - switch (_microphone) { - case MicrophoneState.muted: - track.enabled = false; - break; - case MicrophoneState.unmuted: - track.enabled = true; - break; - } - } - } - - Future offer() async { - await renderer.initialize(); - - connection = await createPeerConnection({ - "iceServers": iceServers.map((e) => e.toMap()).toList(), - "sdpSemantics": "unified-plan", - }); - - connection! - ..onIceCandidate = (candidate) { - _localIceCandidates.add(candidate); - } - ..onTrack = (event) { - if (event.streams.isEmpty) { - return; - } - - for (final track in event.streams.first.getAudioTracks()) { - switch (_speaker) { - case SpeakerState.muted: - track.enabled = false; - break; - case SpeakerState.earphone: - track.enabled = true; - if (!kIsWeb) { - track.enableSpeakerphone(true); - } - break; - case SpeakerState.speaker: - track.enabled = true; - if (!kIsWeb) { - track.enableSpeakerphone(false); - } - break; - } - } - - renderer.srcObject = event.streams.first; - } - ..onConnectionState = (state) { - _connectionState.add(state); - }; - - await connection!.addTransceiver( - kind: RTCRtpMediaType.RTCRtpMediaTypeAudio, - init: RTCRtpTransceiverInit(direction: TransceiverDirection.SendRecv), - ); - await connection!.addTransceiver( - kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, - init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), - ); - - final offer = await connection!.createOffer(); - await connection!.setLocalDescription(offer); - return offer; - } - - Future withRemoteAnswer(RTCSessionDescription answer) async { - await connection!.setRemoteDescription(answer); - } - - RTCPeerConnectionState get state { - final connectionState = connection?.connectionState; - if (connectionState == null) { - return RTCPeerConnectionState.RTCPeerConnectionStateClosed; - } - - return connectionState; - } - - Stream get localIceCandidates { - return _localIceCandidates.stream; - } - - Sink get remoteIceCandidates { - return _remoteIceCandidates.sink; - } - - Future close() async { - _localIceCandidates.close(); - _remoteIceCandidates.close(); - renderer.dispose(); - } -} diff --git a/lib/handlers/call_kit.dart b/lib/handlers/call_kit.dart index 04eeed7..8b13789 100644 --- a/lib/handlers/call_kit.dart +++ b/lib/handlers/call_kit.dart @@ -1,5 +1 @@ -import 'package:dieklingel_app/handlers/call.dart'; -class CallKit { - static Map calls = {}; -} diff --git a/lib/models/call/call.dart b/lib/models/call/call.dart index c7ab25c..be45b84 100644 --- a/lib/models/call/call.dart +++ b/lib/models/call/call.dart @@ -28,6 +28,7 @@ class Call { this.iceServers, ) { WidgetsFlutterBinding.ensureInitialized(); + Helper.setAppleAudioIOMode(AppleAudioIOMode.localAndRemote); _remoteIceCandidates.stream.listen((event) { connection!.addCandidate(event); }); @@ -79,13 +80,13 @@ class Call { case SpeakerState.earphone: track.enabled = true; if (!kIsWeb) { - track.enableSpeakerphone(true); + track.enableSpeakerphone(false); } break; case SpeakerState.speaker: track.enabled = true; if (!kIsWeb) { - track.enableSpeakerphone(false); + track.enableSpeakerphone(true); } break; } @@ -123,30 +124,25 @@ class Call { _localIceCandidates.add(candidate); } ..onTrack = (event) { - if (event.streams.isEmpty) { - return; - } - - for (final track in event.streams.first.getAudioTracks()) { + if (event.track.kind == "audio") { switch (_speaker) { case SpeakerState.muted: - track.enabled = false; + event.track.enabled = false; break; case SpeakerState.earphone: - track.enabled = true; + event.track.enabled = true; if (!kIsWeb) { - track.enableSpeakerphone(true); + event.track.enableSpeakerphone(false); } break; case SpeakerState.speaker: - track.enabled = true; + event.track.enabled = true; if (!kIsWeb) { - track.enableSpeakerphone(false); + event.track.enableSpeakerphone(true); } break; } } - renderer.srcObject = event.streams.first; } ..onConnectionState = (state) { @@ -161,17 +157,6 @@ class Call { kind: RTCRtpMediaType.RTCRtpMediaTypeVideo, init: RTCRtpTransceiverInit(direction: TransceiverDirection.RecvOnly), ); - MediaStream? stream = await _media.open(true, false); - for (final track in stream?.getAudioTracks() ?? []) { - switch (_microphone) { - case MicrophoneState.muted: - track.enabled = false; - break; - case MicrophoneState.unmuted: - track.enabled = true; - break; - } - } final offer = await connection!.createOffer(); await connection!.setLocalDescription(offer); diff --git a/lib/ui/call/active/call_active_view.dart b/lib/ui/call/active/call_active_view.dart index 92dfbe4..0693071 100644 --- a/lib/ui/call/active/call_active_view.dart +++ b/lib/ui/call/active/call_active_view.dart @@ -7,16 +7,27 @@ import 'call_active_view_model.dart'; import 'widgets/microphone_button.dart'; import 'widgets/speaker_button.dart'; -class CallActiveView extends StatelessWidget { +class CallActiveView extends StatefulWidget { const CallActiveView({super.key}); @override - Widget build(BuildContext context) { + State createState() => _CallActiveViewState(); +} + +class _CallActiveViewState extends State { + @override + void initState() { + super.initState(); context.read().onHangup().then((_) { - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.pop(context); - }); + Navigator.pop(context); }); + } + + @override + Widget build(BuildContext context) { + final frameReceived = context.select( + (CallActiveViewModel vm) => vm.firstFrameRenderd, + ); final renderer = context.select( (CallActiveViewModel vm) => vm.renderer, @@ -25,9 +36,10 @@ class CallActiveView extends StatelessWidget { return CupertinoPageScaffold( child: Stack( children: [ - InteractiveViewer( - child: RTCVideoView(renderer), - ), + if (frameReceived) + InteractiveViewer( + child: RTCVideoView(renderer), + ), Align( alignment: Alignment.bottomCenter, child: Row( diff --git a/lib/ui/call/active/call_active_view_model.dart b/lib/ui/call/active/call_active_view_model.dart index 7e61d29..a0d436d 100644 --- a/lib/ui/call/active/call_active_view_model.dart +++ b/lib/ui/call/active/call_active_view_model.dart @@ -8,8 +8,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:mqtt/mqtt.dart' as mqtt; import 'package:path/path.dart'; -import '../../../handlers/call.dart'; import '../../../models/audio/microphone_state.dart'; +import '../../../models/call/call.dart'; import '../../../models/home.dart'; import '../../../models/messages/candidate_message.dart'; import '../../../models/messages/close_message.dart'; @@ -20,6 +20,7 @@ class CallActiveViewModel extends ChangeNotifier { final Call call; final Completer _onHangup = Completer(); final String remoteSessionId; + bool _firstFrameRenderd = false; CallActiveViewModel({ required this.home, @@ -59,6 +60,11 @@ class CallActiveViewModel extends ChangeNotifier { } }, ); + + call.renderer.onFirstFrameRendered = () { + _firstFrameRenderd = true; + notifyListeners(); + }; } set microphone(MicrophoneState state) { @@ -83,6 +89,10 @@ class CallActiveViewModel extends ChangeNotifier { return call.renderer; } + bool get firstFrameRenderd { + return _firstFrameRenderd; + } + Future onHangup() async { return _onHangup.future; } diff --git a/lib/ui/call/active/widgets/microphone_button.dart b/lib/ui/call/active/widgets/microphone_button.dart index 80269a6..7c9370a 100644 --- a/lib/ui/call/active/widgets/microphone_button.dart +++ b/lib/ui/call/active/widgets/microphone_button.dart @@ -27,6 +27,8 @@ class MicrophoneButton extends StatelessWidget { selected: microphone == MicrophoneState.muted, ), PullDownMenuItem.selectable( + // TODO: enable microhone + enabled: false, onTap: () { final vm = context.read(); vm.microphone = MicrophoneState.unmuted; diff --git a/lib/ui/call/outgoing/call_outgoing_view_model.dart b/lib/ui/call/outgoing/call_outgoing_view_model.dart index 8890c2e..a1f5858 100644 --- a/lib/ui/call/outgoing/call_outgoing_view_model.dart +++ b/lib/ui/call/outgoing/call_outgoing_view_model.dart @@ -8,7 +8,7 @@ import 'package:mqtt/mqtt.dart' as mqtt; import 'package:path/path.dart'; import 'package:uuid/uuid.dart'; -import '../../../handlers/call.dart'; +import '../../../models/call/call.dart'; import '../../../models/home.dart'; import '../../../models/messages/answer_message.dart'; import '../../../models/messages/candidate_message.dart'; diff --git a/pubspec.lock b/pubspec.lock index 17b1434..ef77399 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.18.0" convert: dependency: transitive description: @@ -328,14 +328,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.6" - http_methods: - dependency: transitive - description: - name: http_methods - sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" - url: "https://pub.dev" - source: hosted - version: "1.1.1" http_multi_server: dependency: transitive description: @@ -388,26 +380,26 @@ packages: dependency: transitive description: name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.15" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.0" meta: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" mime: dependency: transitive description: @@ -632,7 +624,7 @@ packages: source: hosted version: "0.8.3" rxdart: - dependency: "direct main" + dependency: transitive description: name: rxdart sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" @@ -640,7 +632,7 @@ packages: source: hosted version: "0.27.7" shelf: - dependency: "direct main" + dependency: transitive description: name: shelf sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 @@ -655,14 +647,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - shelf_router: - dependency: "direct main" - description: - name: shelf_router - sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 - url: "https://pub.dev" - source: hosted - version: "1.1.4" shelf_static: dependency: transitive description: @@ -704,26 +688,26 @@ packages: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -744,26 +728,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "3dac9aecf2c3991d09b9cdde4f98ded7b30804a88a0d7e4e7e1678e78d6b97f4" + sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f url: "https://pub.dev" source: hosted - version: "1.24.1" + version: "1.24.9" test_api: dependency: transitive description: name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.6.1" test_core: dependency: transitive description: name: test_core - sha256: "5138dbffb77b2289ecb12b81c11ba46036590b72a64a7a90d6ffb880f1a29e93" + sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.5.9" timezone: dependency: transitive description: @@ -876,6 +860,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + url: "https://pub.dev" + source: hosted + version: "0.3.0" web_socket_channel: dependency: transitive description: @@ -933,5 +925,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.0.6 <3.7.11" + dart: ">=3.2.0-194.0.dev <3.7.11" flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1119cf6..868e394 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,15 +48,12 @@ dependencies: path: packages/mqtt flutter_bloc: ^8.1.2 pull_down_button: ^0.8.1 - shelf_router: ^1.1.4 - shelf: ^1.4.1 http: ^0.13.6 async: ^2.11.0 path: ^1.8.3 mqtt_client: ^10.0.0 provider: ^6.0.5 blueprint: ^0.0.3 - rxdart: ^0.27.7 permission_handler: ^11.0.1 permission_handler_web: ^0.0.2 From e02192d9fdbd58868985ea084866ddc752151a04 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Tue, 21 Nov 2023 21:57:50 +0100 Subject: [PATCH 20/35] feat: disable unusable buttons --- lib/components/core_home_widget.dart | 10 ++-- lib/ui/call/active/call_active_view.dart | 47 ++++++++++--------- .../active/widgets/microphone_button.dart | 4 ++ .../call/active/widgets/speaker_button.dart | 4 ++ 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/lib/components/core_home_widget.dart b/lib/components/core_home_widget.dart index 8282868..80dc4e3 100644 --- a/lib/components/core_home_widget.dart +++ b/lib/components/core_home_widget.dart @@ -90,14 +90,16 @@ class _CoreHomeWidgetState extends State { ), ), const SizedBox(width: 6.0), - CupertinoButton( + const CupertinoButton( padding: EdgeInsets.zero, borderRadius: const BorderRadius.all(Radius.circular(999)), color: Colors.amber, - onPressed: _connectionState == mqtt.ConnectionState.connected + onPressed: null, + // TODO: enable unlock + /*_connectionState == mqtt.ConnectionState.connected ? () => widget.onUnlockPressed?.call() - : null, - child: const Icon( + : null,*/ + child: Icon( CupertinoIcons.lock_fill, ), ), diff --git a/lib/ui/call/active/call_active_view.dart b/lib/ui/call/active/call_active_view.dart index 0693071..20d7731 100644 --- a/lib/ui/call/active/call_active_view.dart +++ b/lib/ui/call/active/call_active_view.dart @@ -40,30 +40,35 @@ class _CallActiveViewState extends State { InteractiveViewer( child: RTCVideoView(renderer), ), - Align( - alignment: Alignment.bottomCenter, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - const MicrophoneButton(), - const SpeakerButton(), - const CupertinoButton( - onPressed: null, - child: Icon(CupertinoIcons.lock_fill), - ), - Hero( - tag: "call_hangup_button", - child: CupertinoButton( - color: Colors.red, + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + const MicrophoneButton(), + const SpeakerButton(), + CupertinoButton( + color: Colors.amber, + onPressed: null, padding: EdgeInsets.zero, borderRadius: BorderRadius.circular(999), - onPressed: () { - context.read().hangup(); - }, - child: const Icon(CupertinoIcons.xmark), + child: const Icon(CupertinoIcons.lock_fill), ), - ) - ], + Hero( + tag: "call_hangup_button", + child: CupertinoButton( + color: Colors.red, + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(999), + onPressed: () { + context.read().hangup(); + }, + child: const Icon(CupertinoIcons.xmark), + ), + ) + ], + ), ), ) ], diff --git a/lib/ui/call/active/widgets/microphone_button.dart b/lib/ui/call/active/widgets/microphone_button.dart index 7c9370a..17c30db 100644 --- a/lib/ui/call/active/widgets/microphone_button.dart +++ b/lib/ui/call/active/widgets/microphone_button.dart @@ -1,4 +1,5 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pull_down_button/pull_down_button.dart'; @@ -41,6 +42,9 @@ class MicrophoneButton extends StatelessWidget { }, buttonBuilder: (context, showMenu) { return CupertinoButton( + color: Colors.lightBlue, + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(999), onPressed: showMenu, child: Icon( (() { diff --git a/lib/ui/call/active/widgets/speaker_button.dart b/lib/ui/call/active/widgets/speaker_button.dart index 0b65dad..d0298a1 100644 --- a/lib/ui/call/active/widgets/speaker_button.dart +++ b/lib/ui/call/active/widgets/speaker_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pull_down_button/pull_down_button.dart'; @@ -50,6 +51,9 @@ class SpeakerButton extends StatelessWidget { }, buttonBuilder: (context, showMenu) { return CupertinoButton( + color: Colors.lightGreen, + padding: EdgeInsets.zero, + borderRadius: BorderRadius.circular(999), onPressed: showMenu, child: Icon( (() { From 76e273c44dda631ab1da2d754e546f67648930a3 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Thu, 23 Nov 2023 08:57:52 +0100 Subject: [PATCH 21/35] chore: use ChangeNotifier instead of bloc --- lib/blocs/home_add_view_bloc.dart | 105 ----------- lib/blocs/home_list_view_bloc.dart | 30 ---- lib/components/core_home_widget.dart | 2 +- lib/components/stream_subscription_mixin.dart | 21 +++ lib/event/system_event.dart | 30 ---- lib/event/system_event_type.dart | 23 --- lib/hive/hive_home_adapter.dart | 8 +- lib/main.dart | 35 +--- lib/models/device.dart | 13 -- lib/models/hive_home.dart | 37 ---- lib/models/home.dart | 24 ++- lib/models/request.dart | 46 ----- lib/models/response.dart | 27 --- lib/repositories/home_repository.dart | 30 ++-- lib/states/call_state.dart | 43 ----- lib/states/home_add_state.dart | 121 ------------- lib/states/home_list_state.dart | 17 -- lib/states/home_state.dart | 25 --- lib/ui/home/home_view.dart | 41 +++-- .../homes/editor/home_editor_view.dart | 115 ++++++++++++ .../homes/editor/home_editor_view_model.dart | 87 +++++++++ lib/ui/settings/homes/homes_view.dart | 90 ++++++++++ lib/ui/settings/homes/homes_view_model.dart | 38 ++++ lib/ui/settings/homes/widgets/list_entry.dart | 43 +++++ lib/ui/settings/settings_view.dart | 11 +- lib/views/home_add_view.dart | 166 ------------------ lib/views/home_list_view.dart | 149 ---------------- 27 files changed, 466 insertions(+), 911 deletions(-) delete mode 100644 lib/blocs/home_add_view_bloc.dart delete mode 100644 lib/blocs/home_list_view_bloc.dart create mode 100644 lib/components/stream_subscription_mixin.dart delete mode 100644 lib/event/system_event.dart delete mode 100644 lib/event/system_event_type.dart delete mode 100644 lib/models/device.dart delete mode 100644 lib/models/hive_home.dart delete mode 100644 lib/models/request.dart delete mode 100644 lib/models/response.dart delete mode 100644 lib/states/call_state.dart delete mode 100644 lib/states/home_add_state.dart delete mode 100644 lib/states/home_list_state.dart delete mode 100644 lib/states/home_state.dart create mode 100644 lib/ui/settings/homes/editor/home_editor_view.dart create mode 100644 lib/ui/settings/homes/editor/home_editor_view_model.dart create mode 100644 lib/ui/settings/homes/homes_view.dart create mode 100644 lib/ui/settings/homes/homes_view_model.dart create mode 100644 lib/ui/settings/homes/widgets/list_entry.dart delete mode 100644 lib/views/home_add_view.dart delete mode 100644 lib/views/home_list_view.dart diff --git a/lib/blocs/home_add_view_bloc.dart b/lib/blocs/home_add_view_bloc.dart deleted file mode 100644 index 1586b84..0000000 --- a/lib/blocs/home_add_view_bloc.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:dieklingel_app/models/device.dart'; -import 'package:dieklingel_app/models/hive_home.dart'; -import 'package:dieklingel_app/models/request.dart'; -import 'package:dieklingel_app/repositories/home_repository.dart'; -import 'package:dieklingel_app/states/home_add_state.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:mqtt/mqtt.dart'; -import 'package:path/path.dart' as path; - -class HomeAddViewBloc extends Bloc { - final HomeRepository homeRepository; - - HomeAddViewBloc(this.homeRepository) : super(HomeAddState()) { - on(_onSubmit); - } - - Future _onSubmit( - HomeAddSubmit event, - Emitter emit, - ) async { - String? nameError; - if (event.name.isEmpty) { - nameError = "Please enter a name"; - } - - String? serverError; - /*RegExp serverRegex = RegExp( - r'^(mqtt|mqtts|ws|wss):\/\/(?:[A-Za-z0-9]+\.)+[A-Za-z0-9]+:\d{1,5}(\/?)$', - ); - if (!serverRegex.hasMatch(event.server)) { - serverError = - "Please enter a server url within the format 'mqtt://server.org:1883/'"; - }*/ - - String? channelError; - RegExp channelRegex = RegExp( - r'^\/?(([a-z])+([a-z.])+([a-z])+(\/?))+$', - ); - if (!channelRegex.hasMatch(event.channel)) { - channelError = - "Please enter a channel prefix within format 'com.dieklingel/main/prefix/'"; - } - - HomeAddFormErrorState errorState = HomeAddFormErrorState( - nameError: nameError, - serverError: serverError, - channelError: channelError, - ); - if (errorState.hasError) { - emit(errorState); - return; - } - - Uri url = Uri.parse(event.server); - Uri uri = Uri.parse( - "${url.scheme}://${url.authority}/${event.channel}#${event.sign}", - ); - - HiveHome home = event.home ?? HiveHome(name: event.name, uri: uri); - home.name = event.name; - home.uri = uri; - home.username = event.username; - home.password = event.password; - home.passcode = event.passcode; - - emit(HomeAddLoadingState()); - final client = Client(home.uri); - try { - await client.connect( - username: home.username ?? "", - password: home.password ?? "", - ); - } catch (e) { - emit( - HomeAddErrorState( - "Could not conenct the the server ${uri.scheme}://${uri.host}:${uri.port} because ${e.toString()}", - ), - ); - return; - } - - await homeRepository.add(home); - emit(HomeAddSuccessfulState()); - - Box settingsBox = Hive.box("settings"); - String? token = settingsBox.get("token"); - - if (token == null) { - return; - } - - await client.publish( - path.normalize("./${home.uri.path}/devices/save"), - Request.withJsonBody( - "GET", - Device( - token, - signs: [home.uri.fragment], - ).toMap(), - ).toJsonString(), - ); - client.disconnect(); - } -} diff --git a/lib/blocs/home_list_view_bloc.dart b/lib/blocs/home_list_view_bloc.dart deleted file mode 100644 index c27d849..0000000 --- a/lib/blocs/home_list_view_bloc.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:dieklingel_app/states/home_list_state.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../repositories/home_repository.dart'; - -class HomeListViewBloc extends Bloc { - final HomeRepository homeRepository; - - HomeListViewBloc(this.homeRepository) : super(HomeListState()) { - on(_onDeleted); - on(_onRefresh); - - add(HomeListRefresh()); - } - - Future _onDeleted( - HomeListDeleted event, - Emitter emit, - ) async { - await homeRepository.delete(event.home); - emit(HomeListState(homes: homeRepository.homes)); - } - - Future _onRefresh( - HomeListRefresh event, - Emitter emit, - ) async { - emit(HomeListState(homes: homeRepository.homes)); - } -} diff --git a/lib/components/core_home_widget.dart b/lib/components/core_home_widget.dart index 80dc4e3..5f08f7d 100644 --- a/lib/components/core_home_widget.dart +++ b/lib/components/core_home_widget.dart @@ -92,7 +92,7 @@ class _CoreHomeWidgetState extends State { const SizedBox(width: 6.0), const CupertinoButton( padding: EdgeInsets.zero, - borderRadius: const BorderRadius.all(Radius.circular(999)), + borderRadius: BorderRadius.all(Radius.circular(999)), color: Colors.amber, onPressed: null, // TODO: enable unlock diff --git a/lib/components/stream_subscription_mixin.dart b/lib/components/stream_subscription_mixin.dart new file mode 100644 index 0000000..8199200 --- /dev/null +++ b/lib/components/stream_subscription_mixin.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +class StreamHandler { + final List _subscriptions = []; + + void subscribe(Stream stream, void Function(T) handler) { + _subscriptions.add(stream.listen(handler)); + } + + Future dispose() async { + await Future.wait( + _subscriptions.map( + (sub) => sub.cancel(), + ), + ); + } +} + +mixin StreamHandlerMixin { + final StreamHandler streams = StreamHandler(); +} diff --git a/lib/event/system_event.dart b/lib/event/system_event.dart deleted file mode 100644 index 82e1fda..0000000 --- a/lib/event/system_event.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:convert'; - -import 'system_event_type.dart'; - -class SystemEvent { - final DateTime timestamp; - final SystemEventType type; - final String payload; - - SystemEvent({ - required this.type, - required this.payload, - }) : timestamp = DateTime.now().toUtc(); - - SystemEvent.fromJson(Map json) - : timestamp = DateTime.parse(json["timestamp"]), - type = SystemEventType.fromString(json["type"]), - payload = json["payload"]; - - Map toJson() => { - 'timestamp': timestamp.toIso8601String(), - 'type': type.toString(), - 'payload': payload, - }; - - @override - String toString() { - return jsonEncode(toJson()); - } -} diff --git a/lib/event/system_event_type.dart b/lib/event/system_event_type.dart deleted file mode 100644 index b018848..0000000 --- a/lib/event/system_event_type.dart +++ /dev/null @@ -1,23 +0,0 @@ -enum SystemEventType { - image("image"), - text("text"), - notification("notification"); - - final String type; - const SystemEventType(this.type); - - static SystemEventType fromString(String value) { - switch (value) { - case "image": - return SystemEventType.image; - case "notification": - return SystemEventType.notification; - } - return SystemEventType.text; - } - - @override - String toString() { - return type; - } -} diff --git a/lib/hive/hive_home_adapter.dart b/lib/hive/hive_home_adapter.dart index 8b1fea2..046d0d1 100644 --- a/lib/hive/hive_home_adapter.dart +++ b/lib/hive/hive_home_adapter.dart @@ -1,14 +1,12 @@ import 'package:dieklingel_app/models/home.dart'; import 'package:hive_flutter/hive_flutter.dart'; -import '../models/hive_home.dart'; - -class HiveHomeAdapter extends TypeAdapter { +class HiveHomeAdapter extends TypeAdapter { @override - HiveHome read(BinaryReader reader) { + Home read(BinaryReader reader) { Map map = reader.readMap().cast(); - HiveHome home = HiveHome.fromMap(map); + Home home = Home.fromMap(map); return home; } diff --git a/lib/main.dart b/lib/main.dart index efd2c12..82f5fb9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,12 @@ -import 'package:dieklingel_app/blocs/home_add_view_bloc.dart'; import 'package:dieklingel_app/ui/home/home_view_model.dart'; import 'package:dieklingel_app/handlers/notification_handler.dart'; -import 'package:dieklingel_app/models/device.dart'; -import 'package:dieklingel_app/models/request.dart'; + import 'package:dieklingel_app/repositories/home_repository.dart'; import 'package:dieklingel_app/repositories/ice_server_repository.dart'; import 'package:dieklingel_app/ui/home/home_view.dart'; +import 'package:dieklingel_app/ui/settings/homes/homes_view_model.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:mqtt/mqtt.dart'; -import 'package:path/path.dart' as path; + import 'package:provider/provider.dart'; import './models/home.dart'; @@ -23,7 +21,6 @@ import 'blocs/ice_server_add_view_bloc.dart'; import 'firebase_options.dart'; import 'hive/hive_home_adapter.dart'; import 'hive/hive_ice_server_adapter.dart'; -import 'models/hive_home.dart'; import 'models/hive_ice_server.dart'; import 'models/ice_server.dart'; @@ -36,7 +33,7 @@ void main() async { ..registerAdapter(HiveIceServerAdapter()); await Future.wait([ - Hive.openBox((Home).toString()), + Hive.openBox((Home).toString()), Hive.openBox((IceServer).toString()), Hive.openBox("settings"), ]); @@ -53,11 +50,11 @@ void main() async { RepositoryProvider(create: (_) => homeRepository), RepositoryProvider(create: (_) => iceServerRepository), ], - child: MultiBlocProvider( + child: MultiProvider( providers: [ - BlocProvider(create: (_) => HomeAddViewBloc(homeRepository)), BlocProvider( create: (_) => IceServerAddViewBloc(iceServerRepository)), + ChangeNotifierProvider(create: (_) => HomesViewModel(homeRepository)) ], child: const App(), ), @@ -102,26 +99,6 @@ class _App extends State { Box settingsBox = Hive.box("settings"); settingsBox.put("token", token); - - Box box = Hive.box((Home).toString()); - for (HiveHome home in box.values) { - final client = Client(home.uri); - await client.connect( - username: home.username ?? "", - password: home.password ?? "", - ); - await client.publish( - path.normalize("./${home.uri.path}/devices/save"), - Request.withJsonBody( - "GET", - Device( - token, - signs: [home.uri.fragment], - ).toMap(), - ).toJsonString(), - ); - client.disconnect(); - } } void initialize() { diff --git a/lib/models/device.dart b/lib/models/device.dart deleted file mode 100644 index 18dd096..0000000 --- a/lib/models/device.dart +++ /dev/null @@ -1,13 +0,0 @@ -class Device { - final String token; - final List signs; - - Device(this.token, {this.signs = const []}); - - Map toMap() { - return { - "token": token, - "signs": signs, - }; - } -} diff --git a/lib/models/hive_home.dart b/lib/models/hive_home.dart deleted file mode 100644 index b4d5cfa..0000000 --- a/lib/models/hive_home.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:dieklingel_app/models/home.dart'; -import 'package:hive_flutter/hive_flutter.dart'; - -class HiveHome extends Home with HiveObjectMixin { - HiveHome({ - required super.name, - required super.uri, - super.username, - super.password, - }); - - factory HiveHome.fromMap(Map map) { - if (!map.containsKey("name")) { - throw "Cannot create Home from Map without name"; - } - if (!map.containsKey("uri")) { - throw "Cannot create Home from Map without uri"; - } - - return HiveHome( - name: map["name"], - uri: Uri.parse(map["uri"]), - username: map["username"], - password: map["password"], - ); - } - - @override - Future save() async { - if (isInBox) { - await super.save(); - return; - } - Box box = Hive.box((Home).toString()); - await box.add(this); - } -} diff --git a/lib/models/home.dart b/lib/models/home.dart index 2f82fcc..b417df8 100644 --- a/lib/models/home.dart +++ b/lib/models/home.dart @@ -1,17 +1,21 @@ +import 'package:uuid/uuid.dart'; + class Home { - String name; - Uri uri; - String? username; - String? password; - String? passcode; + final String id; + final String name; + final Uri uri; + final String? username; + final String? password; + final String? passcode; Home({ + String? id, required this.name, required this.uri, this.username, this.password, this.passcode, - }); + }) : id = (id == null || id.isEmpty) ? const Uuid().v4() : id; factory Home.fromMap(Map map) { if (!map.containsKey("name")) { @@ -22,6 +26,7 @@ class Home { } return Home( + id: map["id"], name: map["name"], uri: Uri.parse(map["uri"]), username: map["username"], @@ -32,6 +37,7 @@ class Home { Map toMap() { return { + "id": id, "name": name, "uri": uri.toString(), "username": username, @@ -52,6 +58,7 @@ class Home { String? passcode, }) => Home( + id: id, name: name ?? this.name, uri: uri ?? this.uri, username: username ?? this.username, @@ -64,7 +71,8 @@ class Home { if (other is! Home) { return false; } - return name == other.name && + return id == other.id && + name == other.name && uri == other.uri && username == other.username && password == other.password && @@ -72,7 +80,7 @@ class Home { } @override - int get hashCode => Object.hash(name, uri, username, password, passcode); + int get hashCode => Object.hash(id, name, uri, username, password, passcode); @override String toString() { diff --git a/lib/models/request.dart b/lib/models/request.dart deleted file mode 100644 index e6c814e..0000000 --- a/lib/models/request.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:convert'; - -class Request { - final String body; - final String method; - final Map headers; - - Request(this.method, this.body, {this.headers = const {}}); - - factory Request.fromMap(Map map) { - String body = map["body"]; - String method = map["method"]; - Map headers = map["headers"]; - - return Request(method, body, headers: headers.cast()); - } - - factory Request.withJsonBody( - String method, - Map body, { - Map headers = const {}, - }) { - String json = jsonEncode(body); - return Request(method, json, headers: headers); - } - - Request withAnswerChannel(String topic) { - Map header = {}; - header.addAll(headers); - header["mqtt_answer_channel"] = topic; - - return Request(method, body, headers: header); - } - - Map toMap() { - return { - "method": method, - "body": body, - "headers": headers, - }; - } - - String toJsonString() { - return jsonEncode(toMap()); - } -} diff --git a/lib/models/response.dart b/lib/models/response.dart deleted file mode 100644 index 4ee34d1..0000000 --- a/lib/models/response.dart +++ /dev/null @@ -1,27 +0,0 @@ -class Response { - final String body; - final Map headers; - final int statusCode; - - Response( - this.statusCode, - this.body, { - this.headers = const {}, - }); - - factory Response.fromMap(Map map) { - String body = map["body"]; - Map headers = map["headers"]; - int statusCode = map["statusCode"]; - - return Response(statusCode, body, headers: headers.cast()); - } - - Map toMap() { - return { - "statusCode": statusCode, - "body": body, - "headers": headers, - }; - } -} diff --git a/lib/repositories/home_repository.dart b/lib/repositories/home_repository.dart index 689c059..a83341e 100644 --- a/lib/repositories/home_repository.dart +++ b/lib/repositories/home_repository.dart @@ -1,36 +1,32 @@ import 'dart:async'; -import 'package:dieklingel_app/models/hive_home.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../models/home.dart'; class HomeRepository { - final Box _homebox = Hive.box((Home).toString()); - final _add = StreamController(); - final _remove = StreamController(); - final _change = StreamController(); + final Box _homebox = Hive.box((Home).toString()); + final _add = StreamController.broadcast(); + final _remove = StreamController.broadcast(); + final _change = StreamController.broadcast(); - List get homes => _homebox.values.toList(); + List get homes => _homebox.values.toList(); Stream get added => _add.stream; Stream get changed => _change.stream; Stream get removed => _remove.stream; - Future add(HiveHome home) async { - if (home.isInBox) { - await home.save(); + Future add(Home home) async { + final exists = _homebox.containsKey(home.id); + await _homebox.put(home.id, home); + if (exists) { _change.add(home); - return; + } else { + _add.add(home); } - await _homebox.add(home); - _add.add(home); } - Future delete(HiveHome home) async { - if (!home.isInBox) { - return; - } - await home.delete(); + Future delete(Home home) async { + _homebox.delete(home.id); _remove.add(home); } } diff --git a/lib/states/call_state.dart b/lib/states/call_state.dart deleted file mode 100644 index b1f0d8a..0000000 --- a/lib/states/call_state.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:dieklingel_app/utils/microphone_state.dart'; -import 'package:dieklingel_app/utils/speaker_state.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; - -class CallState {} - -class CallErrorState extends CallState { - final String errorMessage; - - CallErrorState({required this.errorMessage}); -} - -class CallInitatedState extends CallState {} - -class CallActiveState extends CallState { - final MicrophoneState microphoneState; - final SpeakerState speakerState; - final RTCVideoRenderer renderer; - - CallActiveState({ - required this.microphoneState, - required this.speakerState, - required this.renderer, - }); -} - -class CallEndedState extends CallState {} - -class CallCancelState extends CallEndedState { - final String reason; - - CallCancelState(this.reason); -} - -abstract class CallEvent {} - -class CallStart extends CallEvent {} - -class CallHangup extends CallEvent {} - -class CallToogleMicrophone extends CallEvent {} - -class CallToogleSpeaker extends CallEvent {} diff --git a/lib/states/home_add_state.dart b/lib/states/home_add_state.dart deleted file mode 100644 index 6809664..0000000 --- a/lib/states/home_add_state.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:dieklingel_app/models/hive_home.dart'; - -class HomeAddState {} - -class HomeAddInitialState extends HomeAddState { - final String name; - final String server; - final String username; - final String password; - final String channel; - final String sign; - final String passcode; - - HomeAddInitialState({ - required this.name, - required this.server, - required this.username, - required this.password, - required this.channel, - required this.sign, - required this.passcode, - }); -} - -class HomeAddErrorState extends HomeAddState { - final String errorMessage; - - HomeAddErrorState(this.errorMessage); -} - -class HomeAddLoadingState extends HomeAddState {} - -class HomeAddFormErrorState extends HomeAddState { - final String? nameError; - final String? serverError; - final String? channelError; - - bool get hasError { - return nameError != null || serverError != null || channelError != null; - } - - HomeAddFormErrorState({ - this.nameError, - this.serverError, - this.channelError, - }); -} - -class HomeAddSuccessfulState extends HomeAddState {} - -class HomeAddEvent {} - -class HomeAddInitialize extends HomeAddEvent { - final HiveHome? home; - - HomeAddInitialize({this.home}); -} - -class HomeAdd extends HomeAddEvent { - final HiveHome home; - - HomeAdd({required this.home}); -} - -class HomeAddName extends HomeAddEvent { - final String name; - - HomeAddName({required this.name}); -} - -class HomeAddServer extends HomeAddEvent { - final String server; - - HomeAddServer({required this.server}); -} - -class HomeAddUsername extends HomeAddEvent { - final String username; - - HomeAddUsername({required this.username}); -} - -class HomeAddPassword extends HomeAddEvent { - final String password; - - HomeAddPassword({required this.password}); -} - -class HomeAddChannel extends HomeAddEvent { - final String channel; - - HomeAddChannel({required this.channel}); -} - -class HomeAddSign extends HomeAddEvent { - final String sign; - - HomeAddSign({required this.sign}); -} - -class HomeAddSubmit extends HomeAddEvent { - final HiveHome? home; - final String name; - final String server; - final String username; - final String password; - final String channel; - final String sign; - final String passcode; - - HomeAddSubmit({ - required this.name, - required this.server, - required this.username, - required this.password, - required this.channel, - required this.sign, - required this.passcode, - this.home, - }); -} diff --git a/lib/states/home_list_state.dart b/lib/states/home_list_state.dart deleted file mode 100644 index 8ae9847..0000000 --- a/lib/states/home_list_state.dart +++ /dev/null @@ -1,17 +0,0 @@ -import '../models/hive_home.dart'; - -class HomeListState { - final List homes; - - HomeListState({this.homes = const []}); -} - -class HomeListEvent {} - -class HomeListRefresh extends HomeListEvent {} - -class HomeListDeleted extends HomeListEvent { - final HiveHome home; - - HomeListDeleted({required this.home}); -} diff --git a/lib/states/home_state.dart b/lib/states/home_state.dart deleted file mode 100644 index bcdc499..0000000 --- a/lib/states/home_state.dart +++ /dev/null @@ -1,25 +0,0 @@ -import '../models/hive_home.dart'; - -class HomeState { - final List homes; - - HomeState({this.homes = const []}); -} - -class HomeSelectedState extends HomeState { - final HiveHome home; - - HomeSelectedState({required this.home, super.homes}); -} - -class HomeEvent {} - -class HomeSelected extends HomeEvent { - final HiveHome home; - - HomeSelected({required this.home}); -} - -class HomeRefresh extends HomeEvent {} - -class HomeUnlock extends HomeEvent {} diff --git a/lib/ui/home/home_view.dart b/lib/ui/home/home_view.dart index 8ffe856..c8c9706 100644 --- a/lib/ui/home/home_view.dart +++ b/lib/ui/home/home_view.dart @@ -1,18 +1,21 @@ -import 'package:dieklingel_app/components/core_home_widget.dart'; -import 'package:dieklingel_app/components/fade_page_route.dart'; -import 'package:dieklingel_app/models/home.dart'; -import 'package:dieklingel_app/repositories/ice_server_repository.dart'; -import 'package:dieklingel_app/ui/home/home_view_model.dart'; -import 'package:dieklingel_app/ui/call/outgoing/call_outgoing_view_model.dart'; -import 'package:dieklingel_app/ui/call/outgoing/call_outgoing_view.dart'; -import 'package:dieklingel_app/views/home_add_view.dart'; -import 'package:dieklingel_app/views/ice_server_add_view.dart'; -import 'package:dieklingel_app/ui/settings/settings_view.dart'; import 'package:flutter/cupertino.dart'; import 'package:mqtt/mqtt.dart' as mqtt; import 'package:provider/provider.dart'; import 'package:pull_down_button/pull_down_button.dart'; +import '../../components/core_home_widget.dart'; +import '../../components/fade_page_route.dart'; +import '../../models/home.dart'; +import '../../repositories/home_repository.dart'; +import '../../repositories/ice_server_repository.dart'; +import '../../views/ice_server_add_view.dart'; +import '../call/outgoing/call_outgoing_view.dart'; +import '../call/outgoing/call_outgoing_view_model.dart'; +import '../settings/homes/editor/home_editor_view.dart'; +import '../settings/homes/editor/home_editor_view_model.dart'; +import '../settings/settings_view.dart'; +import 'home_view_model.dart'; + class HomeView extends StatelessWidget { const HomeView({super.key}); @@ -20,8 +23,13 @@ class HomeView extends StatelessWidget { await showCupertinoModalPopup( context: context, builder: (context) { - return const CupertinoPopupSurface( - child: HomeAddView(), + return CupertinoPopupSurface( + child: ChangeNotifierProvider( + create: (_) => HomeEditorViewModel( + context.read(), + ), + child: const HomeEditorView(), + ), ); }, ); @@ -76,8 +84,13 @@ class _Content extends StatelessWidget { await showCupertinoModalPopup( context: context, builder: (context) { - return const CupertinoPopupSurface( - child: HomeAddView(), + return CupertinoPopupSurface( + child: ChangeNotifierProvider( + create: (_) => HomeEditorViewModel( + context.read(), + ), + child: const HomeEditorView(), + ), ); }, ); diff --git a/lib/ui/settings/homes/editor/home_editor_view.dart b/lib/ui/settings/homes/editor/home_editor_view.dart new file mode 100644 index 0000000..bac90e7 --- /dev/null +++ b/lib/ui/settings/homes/editor/home_editor_view.dart @@ -0,0 +1,115 @@ +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; + +import 'home_editor_view_model.dart'; + +class HomeEditorView extends StatefulWidget { + const HomeEditorView({super.key}); + + @override + State createState() => _HomeEditorView(); +} + +class _HomeEditorView extends State { + @override + Widget build(BuildContext context) { + final name = context.select((HomeEditorViewModel vm) => vm.name); + final server = context.select((HomeEditorViewModel vm) => vm.server); + final username = context.select((HomeEditorViewModel vm) => vm.username); + final password = context.select((HomeEditorViewModel vm) => vm.password); + final channel = context.select((HomeEditorViewModel vm) => vm.channel); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Text("Cancel"), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + middle: const Text("Home"), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () async { + final vm = context.read(); + await vm.save(); + if (!mounted) { + return; + } + Navigator.pop(context); + }, + child: const Text("Save"), + ), + ), + backgroundColor: CupertinoColors.systemGroupedBackground, + child: SafeArea( + child: ListView( + clipBehavior: Clip.none, + children: [ + CupertinoFormSection.insetGrouped( + header: const Text("Configuration"), + children: [ + CupertinoTextFormFieldRow( + prefix: const Text("Name"), + initialValue: name, + onChanged: (value) { + context.read().name = value; + }, + ), + ], + ), + CupertinoFormSection.insetGrouped( + header: const Text("Server"), + children: [ + CupertinoTextFormFieldRow( + prefix: const Text("Server URL"), + initialValue: server, + onChanged: (value) { + context.read().server = value; + }, + ), + CupertinoTextFormFieldRow( + prefix: const Text("Username"), + initialValue: username, + onChanged: (value) { + context.read().username = value; + }, + ), + CupertinoTextFormFieldRow( + prefix: const Text("Password"), + initialValue: password, + obscureText: true, + onChanged: (value) { + context.read().password = value; + }, + ), + ], + ), + CupertinoFormSection.insetGrouped( + header: const Text("Channel"), + children: [ + CupertinoTextFormFieldRow( + prefix: const Text("Channel Prefix"), + initialValue: channel, + onChanged: (value) { + context.read().channel = value; + }, + ), + ], + ), + CupertinoFormSection.insetGrouped( + header: const Text("Doorunit"), + children: [ + CupertinoTextFormFieldRow( + prefix: const Text("Passcode"), + obscureText: true, + ), + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/settings/homes/editor/home_editor_view_model.dart b/lib/ui/settings/homes/editor/home_editor_view_model.dart new file mode 100644 index 0000000..faa1a4f --- /dev/null +++ b/lib/ui/settings/homes/editor/home_editor_view_model.dart @@ -0,0 +1,87 @@ +import 'package:dieklingel_app/models/home.dart'; +import 'package:dieklingel_app/repositories/home_repository.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:path/path.dart'; +import 'package:uuid/uuid.dart'; + +class HomeEditorViewModel extends ChangeNotifier { + final HomeRepository homeRepository; + late final String _id; + String _name; + String _server; + String _username; + String _password; + String _channel; + bool _isLoading = false; + + HomeEditorViewModel(this.homeRepository, {Home? home}) + : _id = home?.id ?? const Uuid().v4(), + _name = home?.name ?? "", + _server = home == null + ? "" + : "${home.uri.scheme}://${home.uri.host}:${home.uri.port}", + _username = home?.username ?? "", + _password = home?.password ?? "", + _channel = home == null ? "" : normalize("./${home.uri.path}"); + + bool get isLoading { + return _isLoading; + } + + set name(String value) { + _name = value; + notifyListeners(); + } + + String get name => _name; + + set server(String value) { + _server = value; + notifyListeners(); + } + + String get server => _server; + + set username(String value) { + _username = value; + notifyListeners(); + } + + String get username => _username; + + set password(String value) { + _password = value; + notifyListeners(); + } + + String get password => _password; + + set channel(String value) { + _channel = value; + notifyListeners(); + } + + String get channel => _channel; + + Future save() async { + _isLoading = true; + notifyListeners(); + + Uri url = Uri.parse(_server); + Uri uri = Uri.parse("${url.scheme}://${url.authority}/$_channel"); + + // TODO: check connection + await homeRepository.add( + Home( + id: _id, + name: name, + uri: uri, + username: _username, + password: _password, + ), + ); + + _isLoading = false; + notifyListeners(); + } +} diff --git a/lib/ui/settings/homes/homes_view.dart b/lib/ui/settings/homes/homes_view.dart new file mode 100644 index 0000000..d4c035e --- /dev/null +++ b/lib/ui/settings/homes/homes_view.dart @@ -0,0 +1,90 @@ +import 'package:dieklingel_app/repositories/home_repository.dart'; +import 'package:dieklingel_app/ui/settings/homes/editor/home_editor_view.dart'; +import 'package:dieklingel_app/ui/settings/homes/editor/home_editor_view_model.dart'; +import 'package:dieklingel_app/ui/settings/homes/homes_view_model.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:provider/provider.dart'; + +import '../../../models/home.dart'; +import 'widgets/list_entry.dart'; + +class HomesView extends StatelessWidget { + const HomesView({super.key}); + + void _onEditHome(BuildContext context, [Home? home]) async { + await Navigator.push( + context, + CupertinoModalPopupRoute( + builder: (context) => CupertinoPopupSurface( + child: ChangeNotifierProvider( + create: (_) => HomeEditorViewModel( + context.read(), + home: home, + ), + child: const HomeEditorView(), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground, + navigationBar: CupertinoNavigationBar( + middle: const Text("Home"), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.add), + onPressed: () => _onEditHome(context), + ), + ), + child: SafeArea( + child: Builder( + builder: (context) { + final homes = context.select( + (HomesViewModel vm) => vm.homes, + ); + + if (homes.isEmpty) { + return Center( + child: CupertinoButton( + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.add), + Text("add your first Home"), + ], + ), + onPressed: () => _onEditHome(context), + ), + ); + } + + return ListView( + children: [ + CupertinoListSection.insetGrouped( + children: List.generate( + homes.length, + (index) { + final home = homes[index]; + return ListEntry( + home: home, + onTap: () => _onEditHome(context, home), + onDismiss: () { + final vm = context.read(); + vm.deleteHome(home); + }, + ); + }, + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/ui/settings/homes/homes_view_model.dart b/lib/ui/settings/homes/homes_view_model.dart new file mode 100644 index 0000000..0cbad15 --- /dev/null +++ b/lib/ui/settings/homes/homes_view_model.dart @@ -0,0 +1,38 @@ +import 'package:dieklingel_app/components/stream_subscription_mixin.dart'; +import 'package:dieklingel_app/repositories/home_repository.dart'; +import 'package:flutter/cupertino.dart'; + +import '../../../models/home.dart'; + +class HomesViewModel extends ChangeNotifier with StreamHandlerMixin { + final HomeRepository homeRepository; + + HomesViewModel(this.homeRepository) { + streams.subscribe( + homeRepository.added, + (Home home) => notifyListeners(), + ); + streams.subscribe( + homeRepository.changed, + (Home home) => notifyListeners(), + ); + streams.subscribe( + homeRepository.removed, + (Home home) => notifyListeners(), + ); + } + + List get homes { + return homeRepository.homes; + } + + void deleteHome(Home home) { + homeRepository.delete(home); + } + + @override + void dispose() { + streams.dispose(); + super.dispose(); + } +} diff --git a/lib/ui/settings/homes/widgets/list_entry.dart b/lib/ui/settings/homes/widgets/list_entry.dart new file mode 100644 index 0000000..1e710f5 --- /dev/null +++ b/lib/ui/settings/homes/widgets/list_entry.dart @@ -0,0 +1,43 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../../../models/home.dart'; + +class ListEntry extends StatelessWidget { + final Home home; + final void Function() onTap; + final void Function() onDismiss; + + const ListEntry({ + super.key, + required this.home, + required this.onTap, + required this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + return Dismissible( + key: UniqueKey(), + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + child: const Padding( + padding: EdgeInsets.only(right: 8.0), + child: Icon( + CupertinoIcons.trash, + color: Colors.white, + ), + ), + ), + direction: DismissDirection.endToStart, + onDismissed: (direction) => onDismiss(), + child: CupertinoListTile( + title: Text(home.name), + onTap: () => onTap(), + leading: const Icon(CupertinoIcons.home), + trailing: const Icon(CupertinoIcons.chevron_forward), + ), + ); + } +} diff --git a/lib/ui/settings/settings_view.dart b/lib/ui/settings/settings_view.dart index 5ffcf8a..737bd34 100644 --- a/lib/ui/settings/settings_view.dart +++ b/lib/ui/settings/settings_view.dart @@ -1,15 +1,16 @@ +import 'package:dieklingel_app/ui/home/home_view_model.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:provider/provider.dart'; -import '../../blocs/home_list_view_bloc.dart'; import '../../blocs/ice_server_list_view_bloc.dart'; import '../../repositories/home_repository.dart'; import '../../repositories/ice_server_repository.dart'; import 'about/about_view.dart'; -import '../../views/home_list_view.dart'; import '../../views/ice_server_list_view.dart'; +import 'homes/homes_view.dart'; class SettingsView extends StatelessWidget { const SettingsView({super.key}); @@ -31,11 +32,11 @@ class SettingsView extends StatelessWidget { Navigator.push( context, CupertinoPageRoute( - builder: (context) => BlocProvider( - create: (_) => HomeListViewBloc( + builder: (context) => ChangeNotifierProvider( + create: (_) => HomeViewModel( context.read(), ), - child: const HomeListView(), + child: const HomesView(), ), ), ); diff --git a/lib/views/home_add_view.dart b/lib/views/home_add_view.dart deleted file mode 100644 index f0c59c4..0000000 --- a/lib/views/home_add_view.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'package:dieklingel_app/blocs/home_add_view_bloc.dart'; -import 'package:dieklingel_app/states/home_add_state.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:path/path.dart' as path; -import '../models/hive_home.dart'; - -class HomeAddView extends StatefulWidget { - final HiveHome? home; - - const HomeAddView({super.key, this.home}); - - @override - State createState() => _HomeAddView(); -} - -class _HomeAddView extends State { - late final _name = TextEditingController(text: widget.home?.name); - late final _server = TextEditingController( - text: widget.home == null - ? null - : "${widget.home!.uri.scheme}://${widget.home!.uri.host}:${widget.home!.uri.port}", - ); - late final _username = TextEditingController(text: widget.home?.username); - late final _password = TextEditingController(text: widget.home?.password); - late final _channel = TextEditingController( - text: widget.home == null - ? null - : path.normalize("./${widget.home!.uri.path}"), - ); - late final _sign = TextEditingController(text: widget.home?.uri.fragment); - late final _passcode = TextEditingController(text: widget.home?.passcode); - - @override - Widget build(BuildContext context) { - return BlocConsumer( - listener: (context, state) { - if (state is HomeAddSuccessfulState) { - Navigator.of(context).pop(); - } - if (state is HomeAddErrorState) { - showCupertinoDialog( - context: context, - builder: (BuildContext context) { - return CupertinoAlertDialog( - title: const Text("Error"), - content: Text(state.errorMessage), - actions: [ - CupertinoButton( - child: const Text("Ok"), - onPressed: () { - Navigator.of(context).pop(); - }, - ) - ], - ); - }, - ); - } - }, - builder: (context, state) { - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - leading: CupertinoButton( - padding: EdgeInsets.zero, - child: const Text("Cancel"), - onPressed: () { - Navigator.of(context).pop(); - }), - middle: const Text("Home"), - trailing: state is HomeAddLoadingState - ? const CupertinoActivityIndicator() - : CupertinoButton( - padding: EdgeInsets.zero, - onPressed: () { - context.read().add( - HomeAddSubmit( - home: widget.home, - name: _name.text, - server: _server.text, - username: _username.text, - password: _password.text, - channel: _channel.text, - sign: _sign.text, - passcode: _passcode.text, - ), - ); - }, - child: const Text("Save"), - ), - ), - backgroundColor: CupertinoColors.systemGroupedBackground, - child: SafeArea( - child: ListView( - clipBehavior: Clip.none, - children: [ - CupertinoFormSection.insetGrouped( - header: const Text("Configuration"), - children: [ - CupertinoTextFormFieldRow( - prefix: const Text("Name"), - controller: _name, - validator: (value) => state is HomeAddFormErrorState - ? state.nameError - : null, - autovalidateMode: AutovalidateMode.always, - ), - ], - ), - CupertinoFormSection.insetGrouped( - header: const Text("Server"), - children: [ - CupertinoTextFormFieldRow( - prefix: const Text("Server URL"), - controller: _server, - validator: (value) => state is HomeAddFormErrorState - ? state.serverError - : null, - autovalidateMode: AutovalidateMode.always, - ), - CupertinoTextFormFieldRow( - prefix: const Text("Username"), - controller: _username, - ), - CupertinoTextFormFieldRow( - prefix: const Text("Password"), - obscureText: true, - controller: _password, - ), - ], - ), - CupertinoFormSection.insetGrouped( - header: const Text("Channel"), - children: [ - CupertinoTextFormFieldRow( - prefix: const Text("Channel Prefix"), - validator: (value) => state is HomeAddFormErrorState - ? state.channelError - : null, - autovalidateMode: AutovalidateMode.always, - controller: _channel, - ), - CupertinoTextFormFieldRow( - prefix: const Text("Sign"), - controller: _sign, - ) - ], - ), - CupertinoFormSection.insetGrouped( - header: const Text("Doorunit"), - children: [ - CupertinoTextFormFieldRow( - prefix: const Text("Passcode"), - obscureText: true, - controller: _passcode, - ), - ], - ) - ], - ), - ), - ); - }, - ); - } -} diff --git a/lib/views/home_list_view.dart b/lib/views/home_list_view.dart deleted file mode 100644 index 44b79b8..0000000 --- a/lib/views/home_list_view.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:dieklingel_app/blocs/home_list_view_bloc.dart'; -import 'package:dieklingel_app/states/home_list_state.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../models/hive_home.dart'; -import 'home_add_view.dart'; - -class HomeListView extends StatelessWidget { - const HomeListView({super.key}); - - void _onEditHome(BuildContext context, [HiveHome? home]) async { - final bloc = context.read(); - - await Navigator.push( - context, - CupertinoModalPopupRoute( - builder: (context) => CupertinoPopupSurface( - child: HomeAddView( - home: home, - ), - ), - ), - ); - bloc.add(HomeListRefresh()); - } - - @override - Widget build(BuildContext context) { - return CupertinoPageScaffold( - backgroundColor: CupertinoColors.systemGroupedBackground, - navigationBar: CupertinoNavigationBar( - middle: const Text("Home"), - trailing: CupertinoButton( - padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.add), - onPressed: () => _onEditHome(context), - ), - ), - child: SafeArea(child: BlocBuilder( - builder: (context, state) { - if (state.homes.isEmpty) { - return Center( - child: CupertinoButton( - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.add), - Text("add your first Home"), - ], - ), - onPressed: () => _onEditHome(context), - ), - ); - } - - return ListView( - children: [ - CupertinoListSection.insetGrouped( - children: [ - for (HiveHome home in state.homes) ...[ - Dismissible( - key: UniqueKey(), - background: Container( - color: Colors.red, - alignment: Alignment.centerRight, - child: const Padding( - padding: EdgeInsets.only(right: 8.0), - child: Icon( - CupertinoIcons.trash, - color: Colors.white, - ), - ), - ), - direction: DismissDirection.endToStart, - onDismissed: (direction) async { - context - .read() - .add(HomeListDeleted(home: home)); - }, - child: CupertinoListTile( - title: Text(home.name), - onTap: () => _onEditHome(context, home), - leading: const Icon(CupertinoIcons.home), - trailing: const Icon(CupertinoIcons.chevron_forward), - ), - ), - ], - ], - ), - ], - ); - }, - ) - - /* StreamBuilder( - stream: context.bloc().homes, - builder: ( - BuildContext context, - AsyncSnapshot> snapshot, - ) { - if (!snapshot.hasData) { - return const Padding( - padding: EdgeInsets.all(20), - child: CupertinoActivityIndicator(), - ); - } - - List homes = snapshot.data!; - - return ListView.builder( - itemCount: homes.length, - itemBuilder: (context, index) { - HiveHome home = homes[index]; - - return Dismissible( - key: UniqueKey(), - background: Container( - color: Colors.red, - alignment: Alignment.centerRight, - child: const Padding( - padding: EdgeInsets.only(right: 8.0), - child: Icon( - CupertinoIcons.trash, - color: Colors.white, - ), - ), - ), - direction: DismissDirection.endToStart, - onDismissed: (direction) async { - await home.delete(); - }, - child: CupertinoInkWell( - onTap: () => _onHomeEditViewPressed(context, home), - child: CupertinoFormRow( - prefix: Text(home.name), - child: const Icon(CupertinoIcons.forward), - ), - ), - ); - }, - ); - }, - )),*/ - ), - ); - } -} From e237661df416e04d3d5a70c9d02e06533d8875c2 Mon Sep 17 00:00:00 2001 From: KoiFresh Date: Thu, 23 Nov 2023 18:29:56 +0100 Subject: [PATCH 22/35] feat: redesing home view --- assets/images/house.png | Bin 0 -> 305728 bytes lib/models/call/call.dart | 6 +- .../call/active/call_active_view_model.dart | 1 + .../outgoing/call_outgoing_view_model.dart | 10 +- lib/ui/home/home_view.dart | 222 ++++++------------ lib/ui/home/home_view_model.dart | 56 +++-- lib/ui/home/widgets/app_bar_add.dart | 61 +++++ lib/ui/home/widgets/app_bar_menu.dart | 60 +++++ lib/ui/home/widgets/home_body.dart | 99 ++++++++ .../home/widgets/home_connection_state.dart | 45 ++++ pubspec.yaml | 4 +- 11 files changed, 388 insertions(+), 176 deletions(-) create mode 100644 assets/images/house.png create mode 100644 lib/ui/home/widgets/app_bar_add.dart create mode 100644 lib/ui/home/widgets/app_bar_menu.dart create mode 100644 lib/ui/home/widgets/home_body.dart create mode 100644 lib/ui/home/widgets/home_connection_state.dart diff --git a/assets/images/house.png b/assets/images/house.png new file mode 100644 index 0000000000000000000000000000000000000000..411ef88eef5d08c1a5b6db94745ccf932537cc53 GIT binary patch literal 305728 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

?k8EPG?Dc-Yc#A>j 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ⅆK?+KT}1k(st; zzmIKug?#oQ&K=6eYUrS-#X%i^OZ2%Y^A!*Bx2U9vCNE#I7$X-LmM6bxw)fOr$x+~+ z+TCw#un&HiZ-2eK9U%75-2eCC0c;7*1o{H=|L8p)aQAHg+u;R$Edc(f|4oovjK>D< zb?4El#{zVLvnX`|_q+dcam@4FW8)zHn0x)v4;S0@pK&n~DRy2*4*StiE832M!ZnmT z>{V2X*~)f&h;LFJnF|qGa$rRr0<|=986oBisHV(Gx0dEQ+u2!R+YW5Bj?;LduV7Vt zV}>30nAPoGB2`vz)0wbBh@4BHiWaR*w?-j zaUbpcMq{i5b!RAG9m3z3j@mbM0gHq(E^u<0)0vR{1rn5yg|JA7<3fnxq)%dX8dnUk zk|&BJcasmery|}<2h8(U>%?QB0B}VTq0J<+&*i(!WYrnU_skyPdnjivreY8G*H5$Q z{H|#JzNmEB76x%qB(qHnixofqZu$`PKMK}OE1PZGYA<;A5%Dgt>klskOqbc6*RHaA z&#tvcZr^NA+_N3<-sf`OuOnD{o2Xl&wU1suYhQi#5Iw%-xF*X~k8P@Me{_PiPpB2& zYx~#L+V%zIP+n08#(Zo!;N4p%+MGy%y4qV$FSOfEEoR}+&*-B~Xcx8LeORjI0N&9! z&;ce)9EB+u>QF)>fGLnb2PJF11UZO!@}On~MjuKH~c2uM{8h%2HaN>dc==yL(| zcs~3J9TQRJ>?;S{0!}6(ZR>Kd>`%G3=`-r7v8@5TmGjn!6L>3{2kiA71>P#9n#N@G zALYDNW9!X%*Kw|^zj6)>G)3QeMBj=|lQ|b$gbD~Lx|G9IC4gN+?po2ei@*iMZUh0| zLDqv1A$!u`%{s3(m#N=0Qm|Q zPy+2(?#J@F9(%#tJWLHTP%+yZb%mwQtn zY%dl?K&@lxfSX&fEG`1q^W)&Hs9WHj&SKSpH+f}wzZX(@u)06RR&*uV!nOn(X`5+_ zVd9o`!~oo)(lZ@sZ{>EBu@xX6<#~-u5Vlz>XRkn^t=0a<6Y zzde5EVzVEfu_UxTq_UP?Ps8&FT#&4^^4!RdWYtNesXonMhlk*0=`|5N} zMFQSY416=@M*-mBMBoB&z?(?CiTiDU_k8NZ>&hbS$U%ar-BosQSCt)L9NNPzk5BHY zu>tl^PCLN?bEg#DTb^Y+N%JU1A*s&t)e~*yy+p~(S&MI?2l_tP2j**4_h^0+_%32z z_t7;~PHL1Oqk>3Vs&D~sJk+?#R%dWNOy<0q3Xm&uk7N<6{UZz1_HA^*L$%*h;A0&u zIJV%=QQz!GCrR*vIW(2eoy_~R5UAp_^?81rPxJLRn4`m?@}(Qf?44*&y}!#o|KrW2@ypP!M*-!+>{0)MseUGQwr$61o^B&sV0rNkAw7^!;xM=oYw)xGr-sgUM>jiB^ z>%TG~l|{+93b(sdnI%P@7FzS}~o<+K(ViyN2H0 zn7YVnREq3?85kv~Sw;u+IG`U(sh3uV-K;2UR(UKkEw>$$VUZIqaF@O>w=KEi!KP!>@xs|VDdz9P*>@lQ|Y``Y`ZpeLYsHnv0c+-6igJcPT$6pS194CJ zR@S(hE9MVY`mSNX(o@J=N~BH(u5t;n0dUq#eiJ3E*>B>>L- z8UWi3z`K^HyQu1t=FXfj~fT?I6&Lcg4Cf zxT4&Zh4CHT@finhu_j7==++C|Vpm-09*Z;w-a5vr+2@#uU2xsz0Phqn3|J({YO}UD z!tOmcY&Rcox3dSE?D_+Zc7}1yu1b2#$J^%NOxHqk>*6fxxEaceL$P}zaDW}~-UxUr z`d-UWEp;z#P7k7X4gtJXW+v-hsp{nwAb^&OU$Fyk<-GH4U~Ztz<9zZi*yM??mcAjJ ze*!0!iH4zEmzE`;^RFHuR##1cJ%0Cc!25Cg;j>%qou{|jrMtWA!5c?V)T1Cr6jlwr z-c97)0;A{h-8huVO>Uq8;O4d;;MNd$I{+t`>8^^vUxB$50Pn;Hy$OSvdal7^?2{va z^&tWGu4-s>zm8Sean8Fn~R$0N(u`nmd!SUq#MZfM&N5?b7ohnmKPSwDrn+ zI0now>KoC2t%qJuCJ0I=@TuJbm;&ytyE!L&JwmgxAsSCTJ=spbjA08A{laU&h6~iY z-!|EnVZO5df>=_(y8tZ!0Nzc*m92=1mu&X4Vj}cq=O_`rZHg^o&TiYwafdODBLE6z z0g=<;abGJRVSb&%qT_+d_Q;<~=wE>c$k9o5^;wL-{uF8Jo|#~W-e!?;8-i3mRu!EcfY!5kG^}%);<yd ze&=8Jw=hnCTi#EzLw7+fVWV2I04j)+q~RdO35dfgR-g>*z>RA~sr4O*v8B)W+Q3cJ zCf`T|w2TQA5ZnLyMBDHy*# z6GnOBglaVe!KDhyu;m(*kwon}8RMBeZ$|Xii7GKMAqmf4e+vc#Wu?0OKI$Wh+LZH2 zR?dbAL-f6C3pf$bz=pL1`ahj$+@Fb93xW_%Vn4(!(L@P8T;zV_U1g-=5ABu;4HS#o zQfhJKfZ{pff}wnZc^rkQLKLM$A%K1=-%WO?SzUm57n4ObRKEml{9WbU)zAAX@;m9U z2Q%1S;4HQx9-&ACxu>kMAS=v`bIx$nm@KCP?1Ly-x2|utL)(XJ|CU|^Ys2Kd`|R9t zL~Ey(BOqJqBJU^e+hI@Mw~feq7m+vM{p?A4bl-qj?G}$%?VFdN$|Y3u0=P$Q{xjfx z8Ss9NI`Ic?qM!HDY78pEoG5D9U=}SwlN@c{-<)iBT)WtAKQ(OUsP{g5SOCr<;_#py zUY$)7hiIoZ7i~L$eeOA&*ov6o@&SYrD8@n-F+~o@)iYWC+bRX~_;3rz1>%lEAE#oS zGF@e1(B~4OsjSI)X#>0xr3s{+s#!EUl&mNBDw7Q+5u(WYIYd4AsA&~tGgMNQ$uZ7g zAs|DRnSh>3#bkocCWJ!6p1VE zJxj-d$lKMZ%kx~d@$6GaopT*HK_UB37On0Wc?D{Kx2ST}*lPhMqZyU?DcTlj>sH;* zR5InnYRoGcNs74nTs}{A@dvl`*dN}$!JfXb8QsNpd+tHTL)-1;$M)LU{S8Fm@wOci z+b+hoC0PJD0}vlwmTb8{7U)kYGWv+cC z``uC&OP%cRqniqBs652c<`R~Q&C~U&l2Ns+RRbKI6Vvf5P)&0o=bym4vo6pcyK9My zynp=Sc6;|CO@i+1u}5zih8?03hx4%t@a`nv-A=#sApm=*AqKG;;4R>;6L`ls@cwP| zEd_cvrJ3@3?}koqkvm*vq#fN;=K%ZQ4$nBWv&!~yd**0^wUaU!ycQ1&f`;s7j#Gmt zU#)!hlA9*sc`?xiO{GgY?!_L^RLQCjzhwr;V9u~;c7h4!D0Tm8+T$smO@K0&pT(R3 zqICds4z8K29%SzQ1|yk+lkCtdlkLtQ@!E%pt;HOxpDLpsqV7J_``Z>L*{*AX>?TUT zPQRyK6_i4*rOq3$KlBm{Dz4#VqUn~sEC7kzqxpLUQ0gIkZ(sI(({?Cz^ikR-fca4K zbUWWm1}}=%ulaD2ZM!?!_TM`jK=wr08tTV=dF&_G*3&)DV@2TtaW}lrIZ*e!=6$aN z{@*MF^f~_wrMVUd4!k9A7g#I5Er6C!t3X?(>$;V9s|Vnpv!~v;&W=4+Y`1L&3RZs~8b3~C^cyc%nL2-dO}_*r@TWLxv%WP8F;Rg)glIb(sw{+hQq@Fi)gD0ki6UuVHLjR8jdCUY)``4RSvaJVPful~843jyia5;| z1B4hZfLs;|*?FEm*2#c&9{tQypwx3YnS1U*(6fjWvMb(t+n}xIq**x~kK!?y*|)3L zcC7ERy_j$`3AXeJ~pspbDUSp5mC9@XQd+!0f57?V8oN_|7&py1{ zKL6xC1Zx+^>2gox?ILhG?fm@X+l+T{Xxq={ETh3g6~4rwfOmi`?ay}oyj$ln*)*jP zc>~_p5b*=v=Z*{k-UER6AjTL{n@4%Tn@O*)CLKeUJ}aiCTak7tAQ$6Ng(dYrK(=kfw%yi2}V@8sB_)As9X`ZI&BF|9A)b%M-ye+IA7AZ zumKTS7u6<@+`h={$0zMaoGssbhRFM#E_?FM5l5TLT&D_wVRs=pZvvtr0Na5#d2g5d zhC&~xi)O?+AnyanJ@mQM=ZM&p2j{s~M0KrKL7VTMZwCd~TPy9r_Nrfi{Pey$Td*wE zvhd-^1h|`tm{W;tWd^n5IjUCxYYEoGR!Ym`$Gn#U74L@Y>hJlEM z^^=9EKzp8DrvswW0M55*i?2|x(5cPJ*BC_9fKbFcB{)pE6xec_@ zB}uje^R3nQ&^3;EeeI(Jaqr^6ffMGk+dZEvwlSkPoQfQnPp<3{5 z9{&@*AG}}G{J@zB1b-9k$g>me;^oP9)4MtL@W%@rVE-1Jbzk7^qHgyZfX-uq@h^|L z?|IKSxc>vtdw~0&_Yd%X@i&pSry;rO;AHmhZLj7vcmL??m+a107qWBXZQ+Fg=#YuF z{pK0A^EF@FL#MAe=#14&&`)1_)UKkp^Tr$MEpLz#tNaWbIYmwb0G>;PncjvNg_T)q zJEjuUR4>{Yh7b8&97D&}*hOWZ=vYIaQqqP2L}%%kOMCW6IsUyHDjvSRdQ;tm#gR?lDoLX^x&8Vq$bO|&>CuF?Qm&|1kfB#=kB??m+orXG8` zFVr}WZ99z;YUC`$JK82W4ctPCJ>5ATDYJvZ`1W?)3ol1O`DWH}g`gr6fGZY8hyyhZq>^IGzZo}S_!GZ3lTW#%HY5{bC}Pm#BP+XLRz$I=qAp2)kF$U7fVO(Ec| zR+|Dc7j=(=x9%(Q7VtLW^DOXI1g<=GJ#={;pxlC|b_Z0b8f|ESFduecK4neyL}zug zXCP)%Ys^rqV|W0ZTT$@d=w0B=^Cj#*<+}ymj_Ms>goydb6nG1`87Tm_nyo1ER#dKN zT|?llayfOCQeC*p#PW$mz2Ke20!mc3z*|}Zm-FVaEByl0wZC0nTPY9Tn+IS^*O15Y zR&x}0pOM32SdUf1F2PzJ+jmweZ;v>XMM2yA0v3_a*~R-e+T(YvbE%uB?%illG9CxO zpMGG}ULpcNzHKfJY(#R)0C2_*Md3u>TLEz8!3Ex%mHQq_x5dpdHdKL7iUuJI0nHBP ziDpJEb4(o(e-(eXfO9LC@7f749^X`C3pp;;)OpJ`REsKsxQv%V*q77TPfBYE)Rjuf z<(z8deD0E%lZU#6 z`A0}EB!#)6@S$@oR8+0Q{33;UAVG>~@D$Fw=GBpwRLZt&KbyIA;i*8o?!hH?RadsP zFDkHASEpNk<1`B<64qQbk-0XQIlK@mUHho0c`|sxBBd639-ysSImw2wj2%EfAn@M7 zcUn6dgm!9@^_=!7)dP6He9gB$hnw2iB)jgXNie@_@H+fQ0Ptl*bAh1QEj|8q0(dPWeI%q7gX?h4bDVtXVTqma7ShfL}eeL!pV}X`aNy9Li@-V`ZvU z_B0~x>6Cq`Ou~m1sxMy6&azb08yB>mpP^Mb^xJg6*q@bS1d*8^tJV3d-ZzS zgWd=&c_X5=2Wb=ff{VDFU`^ot!#H?9dJxt6atGdJcqOZ!@}hC@R^&a@l4^II1-yy8 zZ-f#*&v4W^k+%oDW1R7cgkTGrGKjnfa7**FRa94}@+<*3b;=rw!~tY>S-Fb!!W;ubKAGVg-cmA`g4i@`3;J6ebmTZ*SsCg#t!n3C{5je53MQTa}Y#Yu^Lj ziktZ})42io3&Sj%S-sCJohOOyLt82iVM{guLF zr76159~{|Ggiks98E_Y^F7>oim}>zirj325;lp($P^jm7qQK7fdn)l$&YTHM|ePA0yx7+Ec$fD*X%*0Y`WF;v6eG7nn-6U&76kAE8tbDUC zsUoS&vk49ySTk2^Zp|B{*E%QU1NG%`0*ASap z3^m?JUVi0$u)7$0?SBVV{NpV8q4t{&l16z+E^C~3>aGY1EP-aE4n^`nBsg z$6*X&`OVodzTT+Xf%m^R#-nZ*k^e{AC=ws1(Y4(_%UR94NC3ROeM^7sQ7Z8E_Ad3d zLz8K{OxYiM^Q_(V`~kb>)gH?v=d7OI>LcyL0rICtap(S3cI{0^Epm34TivgzpJc0_ z#zhQZ_n~E7<%%S$-!1QNCf}wRcHrD9d-%z_Y~?NKHjNcqGy<=i@4Aj1F=mfHd%tzh zE43SLI%rM%!)zJ{Vt!1j?Yb?)!l~mvaO9X>bJtM|s`Yf$+OTrV*r8Kf$4XhLb!kUOQ6b)aE-kc0iqD*0O`>drz;OKfc)RIV#^qK8r!iOEf2W?x>5r1>B#$dyDcM?P(LJ_f zXzn<82iW4SbSGGAtBk>T0`R_miKEVMJU$3`FQAdhIBkx*)DR{uDixF3c~MKIi@eJK zX90KxO*+(ELv1Y8tfPzxtqkxf737sr2akH zTy(lXF$C}~KzZLzWDzp{>OPja67=2)vsS7&Vrn7$&l7 zr1YnG{I+c#mXT1SKICAvAn7Tm0Y+t@blbvM4KXR<5ut1=OX}s6wqpNAUda zq0e=jnyYZV4YU>7Pv5>|mmW~Wz1}W8xW%5~z8iq|GXS{&{KZH1xIFmY)p?E@-_7mr z6}cYzoYFD};Hb}~PG8=R^IC0y9bGb$+_uNONqw@_GFTC{j=5}4OR@ysN*@2Bl2nmoP#>|O`n&)<)6 zR201*SYTFge)j<0ohZ%+ROj75q|F#?f*ok$76IE(BMxy5IL6fqz(t`0;&rIdrA7zf zyD5EZuZ||aU1NK;0@#fG0Qi3Bb4A{Vw^!TtV;NRO#Y-6xbSCLIHTzK2OaPI#B4*83 zqRkTscq*As=PvhvpcLSx=#Jzw?#ne+yP2G}ROvjI4MQ@C5;@g^uYZid?y$!pO^Wbp zn9sJW`CORHaOT`V_FXb5!(O_$jZoq?!a2Q*QrDrrQ1{u1EE+`3_n-0z&VElCQ40uB z`pYlSS?ue{cHx6*cFUKXW28eD0OI$3gQ%HB$fgHzYkOjXJ!T%-T=vtM%=L#p;qy+? za&)H$yal%2;f=WU_}{zleeNG`|MJZr?6WUkbRhmu2XD1CQ{PhEdWRxzJtqKHF5H^~ zmw&77dw~3XTXR#g1+w~TrNdVkj$eL65nCUgVyo{0WVbUJKu_yrkoUHaqRFC3HnMim z4xL$R)m_DQ@tG&>&tH9RZ@>2%j%yL*mYFzF9&SCf%!bz1*xcT1%Yp_kgc2`d5s-$} zV=Ai9-EYsZ;uVpIbvS`{mRaghh{ZzrR1GKC!kZIq=mx$ABDfXT`&d<1v~4)P=yEEN zavF8u(>ZA*9#bhpE;*Y7R+3s|_)nN>S2FQxrKkLP6kw^GPBf8s2q%qP&(r`!WfE$^ zIgJxS-Ma#zL<2Y()j=(g&kAG$^Mjt94s967#4X!Efq0^#epc#nG@YMG#NscTK|a?B z-&iRoVh)naF8SOcb4%>~;*ms>-Zjauv$<=tj_S{K( z@rnKR`g6yLzE3eu;<9$Vef$P3LO(#f_Gb(wyiRM}dcpfi^5sXjK+pHiAzC2+hf&OM zC*X~rd@J^oi#pTn?i-d8c`pLI2Z+8GcmSMn%z^hz*8oGMNsHRDq16XyJ~9n(rnmLD zxrhu#1mYFrATA(x;7*jUPF$XLmI88*zqy*5s2kZVK+!e00Dx2FJI#41MY(fr8&@_< zBqiWb(~d&GqZ{z9qf|_hw`le(7!`qz%TtRsXF^u5WKq}uE_n02l<2|!0&f78)JiM4 z%L37noFnBZ&(+Aofwu%+0y=;#m2*XraVwx8ReAIHaQ6Y_PE^G0S4BIuc{vn(&uqj! zL@E75qbd<<0=!#j0;4W&OcrqD7=yAykZ=@y|lR(yamJ={N{9qz&lxs z7Z#q1vg0&3KBCQaD;iyqcQg)SIRah`hm@n9CGeKNwtka-n{)+AXZ%tQ=Be42=rE53 zlLxRp`(MDV;lSL1I21Xw@9b(nS4Y00Hkj^Ufi?h$7qiIrg7>ozZJ{qc=kPCcK=O7fcGW)`HMU4gXi}E-hKA+LyKJ}Gqq(clcF7^xfW?E6&q}f^8mI3@0oyj zEVqDn6VKzNAQ0~tfGY}zV()LDhdDsr$pU&WU@h==psm}{3Z9!wi{2_*wkFe}IG;p` zOJJrPx8`c8&ZjdEt47=H2cp$nz)|8a<(p;LvYrT7RWAX|XUlFS6}k>pI>&ACt{jWc z#jBwm_KXUe>eW>HFvqO|584mGZh{ADvpjH$YoU!fmNel)KzmVT2+T*2?OKK5&e{kk zQqw%Epr?_1wiM+uZYgD<5zM?hmxY3&@!2dW_FS512VO=~KxDq`KJwi^Pp|_YPOzO& z>APN;VE25^Ha9Z|z%XPYtlRZ)q}}{_+1~;8Z$bROdjI*i?y)C6Sn8n`q9^~Sn(Qatk+DiXTBr9u$_{t^Hc52U!hO$puOQ{ zTE=m}LpeEIQ9moW@|qfJ?rF2Kj&iH*sj-{S-E7}~|AT$}#}94kj%I+in3An2_S}ol z+M-pxHUnjLPF1E=4y4$UT_JP>qjoQqD1}ZuO#`!Sh$z0ZqiyVkSDw0j{QYzbW+Blw znrWTKP+@O{zQl#B>nJD1;X{A(7nO}zED(l3wbK!RVjd0X!A$KX&w>-mRcwgKc<=8oqX zqB@rkc?f?mfauQ;iM&ypzj6uCrWNQruUunqzIYM}{iMD3+BF!poUuQ?f3tn_75O=D zgAyX|%jCO%`0g#lZu_9kSK02x)mEC0%U6DoEu@8OUqiB6^@~Ek=iCar%|+e|oX`3B z<0#Le&z)eciiwg5Ln`hi9kT%T0SD6M>TNB@FrsfY?5NbOT3m9g`?Hz!+vi4FD=u#G zL>7R1^5Gt_n@7bwUIz}#q1?CXz`eAss9VqbJkeerHRZ|&ch8w_^+d{ETU1fBqIMM> zr!k4hUiJSJc?-DZgB?N}Mgh;r$SQX$pQ8iYmM2MyvsEbGQ*Y-MJ~V!bUA&k%V9A3TZz132A{>Os%7mq81cB*EwS?w8Vf%yv7R>Z9@C)^K ziRK?oCkz6h{Bhhn`RpxnPz1~_wp^D&+~NzcdPE7WJ>_;r5x|Y^Mh^g zsx(_N9PSkD8O*f-V1{zm+MiJXzp9X=ikCnwF5g4#`?F0aYVu_)5CrgR$ZKzXk?#%Q zO9j8~^h7&PzWg9yo=kxCz++|hhtD@UMfh)l+M^Kv^*Qh3UvCA?zi#7Il;3`7)Q(?D zx1WCg{-2HAHbH$zU4!!RM&quLx$ZLr?B2F$YL%vKz5P4NB~%TP+dF23TW_omD#p@L#r*_u-LK-Il%^otg5BRsWK1kJ7rHk`>_4- z&mY<88%~g0o^D@!{-q5r>2>0;DwOIyyP=U*plYw8w=U7`{IxTQf?4r3=Zx*&b!6=0 zPyYC~mtMMbxo$Xh?1^U{xx8V|>aol@;Wiap(`mZ+$X8|N#f$|o5&JCF6D{RR^e1~cKM(@>(OD%W)j z)A#UUm8m-JKt=gPka9B;RprBHsWfjEO%gWXrnaQN+EM090q$i3m9`$*eB-!a@p%?_ z%UC2AF_Mf;ve7^|;Tzzs$ooH{%>~|SW)eZkPkE*vf+pzC66NRVH;v9i{lZ1nXRw&d z;#L&1sAlCk8_OeH7d9_wdv6s{w*$=06#yA(Wf^x2(}8al!lxB=p|BUw`G;Qu+u+>rNPeP-=e({(+cH3H7gr?!jmT7xd<4KgdBI>sNldXOW0o_qgFYX$AJ8CvVt7A?jOpxQH z^4+_iox=HyKSXT^i?blxOCElJ5GS9`f@<j-gUH?;0^Knm4(q9{z3)s~L z&C{<`+j{z%>OC@T`S`2X?1gt}Z1VZjHhQuSb@mh&Wfv3mra?JRkK4Lc z>#aC9+g^VDb-VW5X-c-pg_P#oh5Ij#z4E~emd{Cd;OIu%ymPUOXnW3KjDWa72R^+R ziZLS^>YK@ey2cRPi)K*Hk%EVB*Mj~r{{4LH*s;IOZb~1^Do7p6Crg4 zr}+VN395lIuea3;8*KStwJqzLXR8J)ZR4V9+q1rv&SHIbl*s#P(dHPX+<0=3$a@J< zwh@p|u9T#dI{g+6)z4!iTZ zCAM>Tu9c>tlFkpNC1@6_{uFDgjt{iO z##k&DyUb5k^yC5F{Wf>J8z`KH|y9V%9wWCJ0h z7r5I1kv1L+kfj_JT`urmSVKb}8fmQWjHEs}g2+1@gO2EN0QcOsvJlYwTZq7$aoTDn z3acX3kj8oIz?;d}(Y$8^&)|V-nHk znSgf*;N8W7@#OYWTa1{jVkSyvX!K$98%yvtSj-}6aTBg@bj(}C*hTlextX*cWuMjZ zyZai$?f$a})_!`Yi)6HgSJj znJpT~X1;~}W3Ew?5P|k2D1UjKt0YSn%rm$SQUTB~z&3$-FpVf$0fYp{QlVF1l6Cc) z@iYkuv_sDa+P&We*t*+k(_G`J9k1TNU|~>!$;&WCSXtva@iJ)d3anX+x+d8$Lc!%M zMprh6+HM%t!Il{J`BIu!Gw-VeOl53xXVd%U618iAki+M9oJ30iXglTlMvp+PiN^<@ zpI{GuHPM=x$Cm@%+o>}@_Spmn-q*jLWp}?l@ZZ2#51_yEhwJU^>)ABTDz#HjSJ>H? ztL^R=*Z(&@_rLzu39b+6c*AlpC=1AcX<$Bn|JQbEziDpn8kK(xjsC8keXYmVKS9g4 zz4X;ib3@3JOe7vP32Reb) z4X4hGoj!HL*!6cG9b5jCYPg{a??BC7j?l|@niFzW&95H|#DhGD+Q>joyyD)pv5)@r z@z0yLk6xZ4g(rEdASR85MJ1PiKR4mBPx$n)^7fp|GnkapikZ+Gq2~HAv%vmTbb3Z3 z_2}0?331~3K%M&nDpQ%TBUy-~@^{rwb_&Cv#exqfxXcWc?@pqXWh_7v&njIuobg1% zF?^qR(c5gFItMY@71T}s4zWums%7f1Gw47TPuW)tZfQ~EjY8Nq02*F;fsjyWeZ3dU z)d)($;-IZ09Es#P)icIq(SmO-)O34=&96!)H<)fKhgtcp?}aj-YbyrGcMnzB>LI{; zq>8qnEl_0Lc6e(mw0V!SD82dQklk};nLTy2HiXmd3xdGf+O2i`jtR5mrnCv;oz6_V z@BAvegKZpeEBXewZ#*$#CpYAHz?)+!C$p7(g?1z5WU}&98%%Y|s>La+R`#H7;PK6< zljk=e-UC=wvF)yvrFLX=J`*b4h)7YWL(yEK@Y#T}+^`g3s~)@nP$_mGLA1@}I2(Fa zz%80y5xZ`O%BNd%0NOw$zdlALfSbf_dC-VOEC?j9a^1=lc@wb;^aSEk7E7@$0Mu}X zJ`90*68-2`HxM-veV0R-SFzBlBR?!QAVaEn<%eC4SnV4pJ9=D^w+FEQekkAFc2x{r zrtn69PF-3bWE-HAH+4qX5~$NA%JMo$QwYG_;J%c`4#c_kS^=9*l>KU0;=r5VA$s2n z-U8=r)xs<4<~Eya#DO=!EwEO!EkE>M#B4k-FfTw@Rz@_R&;Aqb?TNl&D#r8PTJ#9K zr7cj@ov*aU_`<4?(mzGp#qKzvXW{Ye5*8q z&b*|%A=gFTFFv*lkAf{U+1Y8&J_>LH;Lkncxpe>zb^a{*a7E!4A0#&ph`&kqygRPx zqmfAx;GJW;P@p@1bVO_0`e)jXfoKGB2)?LdQTMVCetRHufzLR2&xRH1W}jXS_$?*B zJeN73nNqxNeEElJ60!KCtZz0(DJ+`0NL4K3S}3CTwj%Eu&cT7ENW1%7zXR`|zqrf( z@ajRkcu$YL`NT@n4D<^}SXrv(Cv#?4Z#j!U=x+zi0NMh;dc=)bHw519aT@^Mp6D5! zNz4NPUZP_^% zbsaDhivU^7xr(q^EdpHinf1`#C8*k$-#{lsSeBJdp|+NV&F-a1wy0|+f8SH4ruon1 z?b`UH+{|({Dw8C)3-h` z$+q8HWXB)x`L848Z{G3sw|~Npb(x)dIn53{RA5IRF0ez7RM^WOKKS4I-2dvEC;ZZI zoM_tH;>`6srRA9ebw%0SdONKA+IU>e$JMm_kL1SRv2Ayh*rwMwuxn|5Suxpu&w=YX z7GmW~$-&WWtYo#HT~W=7y(7vdvg2!Ny6oVg znn@%c3c70hRLf|Au2zKAoikQBoNU3+J)x9VMd2qN1Q6!rWQ?UH$B&89Ry#lCM*Jw( zit%CM2=cecu<165*CvUACjTszx4gksf1NlJ6*_s7fd}{;RP0j1`{J0EfT%4R>P5ci zQ@BuT7EN(wVhQAWmhQ^3(w6BK{X4!R7oC7RQC`(5g(I>~B3CWrm6=37WD z@_zHDnlBfDl=CXPNP}9HXRlizQ1cxQa7Qx92Q!ft%#NWwAO7r|oYmEIP+HZ30nI$S z|RKMa)~5 z&$h?z-a-Ub0DxnY%OojaSMFTmw<5qp)VjdhJIaaHrRtR!ZZ;)p4!mip+Kg4Bz&l%Y zy-ZS47mGSq$&gCNvN&(mtR$0(LXTbNGPYM~nVi5RY>Q#rVt{J(e1SLLgUGvz)JY4D zSyH#BvzSwjvn+a-i~;X%BJXZg$SWu}+R__sTLACnOzMsE z$ZrD98UpD~7EbD=-p+g4E2D|z0dGn)t8)V_o&BBVjl7u*wdhdVB8Ttoz*~_wzeUmc zm14}e?~Gf1F09XEd9K@f!u+?6F`l#J$<63~@^7GKH z**|^g8kBMwf&~~Rk{$-2M~JLP+M&z^)-6enCLeab?u<_FNOs^oLR;WLsPt{?i*5U+ z*?@P61MnU0ma(DOmMzY49S)^1S0fQA#1(aG-i!bo1&{*kC>EdFPPUHS@W|lh#&rmW z5n@&nQA-Uj5v~NjDMaTobD7NVoW!}Je0e4YFmg5PAt;iul|VR;&(~*4Z5|Ks=HU0S zvMbb9w}jhj0DK>j_m;KMw(((%i)i39lQf9}7m4Jgo07;__N;Nad_Lb-twU9BE-{@b z^TOfrva~gTJQ2EM#W>GJ^GEZ0Ha$4S-uvM?`^(QS{a11F@3#Nwv)AmFr`zqolh``n zkzwm@LQ4UNzx;=D|EaR`91@4|!XJ31on2$F!7mY4Z*Xy5u z_onT6AOf{tq~#4E#_5u)8W(6YbQ!Cy&TTPPyEGL+7qydIxKo)F>UQIrc29t1U_F{w z5M|}1skUOn0ss#`U=Bt!?~5av^7lpfMvKK6w9oXLNX{6gwt9I>aF*;#ZDdT81@oF} zaI-6X$5tHcxIDWu?Q$yH1Oc4EL|~6zJpaFwf}t}JRpiF|x;%5kIwtl3B4^5PW+)PY zHktzH`|?@SI0?f6ZLK!_pv6NuNu*F;w8h6(AM~>j06Y-odN34dJdtz?rUOZQ*A#x2 z^5+pm_Y!@C$UNqEsMY0pOxaAzT()PSA)(q7(Xaq|I1{};k-N$)gtMeAW>G%*GjQYvDp*AP-e(LUZOm15-wb*5^J-wHfpogH(kE1p} zLF6s)K4)Kic#Hk@^9KR!7hUfAGVW?WQuF=9eY@<|lOwjBQnAuBfozB^>CAxUOvT+P zn*4hf%G%XL-hek2skb=loSJXG!^yR?tRFS3%C8dH4{H}F^6sNli-rnBGEJC_Gzzp! zDC?u;Wg`@MGd0Vt)H0V6HLr#8dxX!vc2AoF=G8d6Jq>{GTU!Y&4!sG$SLLeQOyyjG*A*1V>2G$N@|?pFi$0@_w+^Eg23_kedH6V7ZD+A@uCLM~CuOmwbu8Mkt6 zQw{l*0H22Z&r_9~<2O|RVryzKt)Uiwt`?t6l5MVRjL1Ya&k}dBC{Sdo$5M=YsBaH& zdj=7;27s(IL?__gL*%&{0NA-8!FCSD+6p4DCL+Zyz`2VBlWqmzUG!2{1m4bPD*Eo8 zA7?da2+}#P1#K9h)#F))Ojb{o;A6)XZbAKzbMgm(~@$S^hIA;gk_LQ z3*|Ql@%t5dXN3__iZ{yr}!cW!)o;)lMA4k#Q`YcK#6WvL>q+-rEK#2h~SH_D!9CS z^O~P$Imw!UR7BKWQ<+wxU(!b(-OhckFRmepW7!ZSZ2 z?TPT4sL3(!&{o|x;@=??r71%{j`1j7CG;C&)OFs-)vuhegW{7Sqr6Na#*9Y*oB)n+3qFfwuK6Tip)sz(ZRO3 zJ)M%3R9EM{f-=ARZ(3t_T}QbXtwFuuEycOOdk|m}P^xjr2Ebd9xANMe#+xZ`Y6iet za0c_-qQ<6%xr36Yc|>WONAm2B)5CVpHS{PyI_N-pgoyvCdp6nWoy|npqR0VFB6*dU zsWoP`nskVkr`%AFb-#+gCr|b&9xnvE+c5%hkv9`XA;+ea^RQG(XpT|7suOt5-O6nX zzwLZR9*=WbnB}r~o5i9`EkEb6z+8(9;9{E2%)^|e3IK2Be3oB(#<+k>C2H9$RMS}i zwlL{;I_ld)cRSE_BN{M{V}TY2l}h;;dJjb@yB8+g-r;y#NquqyWqIB6;{j~8p@+I| z9e`JnGnR-AaPOtV+kB3X09h2dz}st)DqzmhqJ(3i`tA%CB3TS?9$Znk>bqSD8NjdT z{0gp@B1Cy}^DsnWA8F(Zz=@(g;LW}ev*1d@BvA7x@D;5~pyqPiqRO55j*rdn46;>c zl5F&RuC2K($3}0cvvp^3ZRzcawtRb@tv@i^mhDKlMU=<2)y^X4{i?l!i<$uWg(s-z z=KOvBvF(W1Ebc$LiY|6 z*;eBpPF=hVbi!cn1l}t5QqFrW=TIlMzNdCo*lIwun)9KVebCK9bC|NNZjNIIBDGEg zm7PT1%g`DW(&kf(p*r?MUtxmdr?;kQ9j_kw$Mh2vbU}; zve9)#wsj2v4v-7HSFf6dN5vGEyDn?a7?W+O%3f@l`4qmJK)e!aU%7e# z^g8J9tR@egFGfHSxy)nqdIbxBeja{{a<Q^(Y<3Nx#?Mkt_y|lMOM39JL(ii${I-uP&P=V1sD{*oi%70`qxw=kVfN-Q% zQA~p2OmyiuU8M#4A{@eF7#h0zCh`~9;FWOjf&sl!R%Eboj-F-f-gTBeFSmh zXXeLWPRQ{eo6P5~+gbd#)Pm5EzUJ46YH5e-cOyp!VSAeBf&fV)-!G>o54SSB%F ztW14~?xRq-OLP&=37tB7D#9{y=zLBTC&X19(_Hs5fwzPeL3|hW+Kvb8Q&?ogQ=a6< zq9F)C4`S6igGGgU*!r+q^p!_C-!qWkF%w20k?%AUm3lUw?lb*~+@o#75|OB`s| zGwLeytqwz$+KN1DsVQ>zJL^lZL#?uwsyyq4HWzTOT~tSHH$c5}&~87o+4gO0bCmfJ zqVAKsq&9B_ynAWWIfTR7h&_D!8XVTvIGf3rpd8;sY5wj@#~4Qd?^ENEw+Foc<+J;- zO2zBk(dKWv+l#akz5o0Q+df$8Oj_zo!fkPzz&qK2_bQ^}2Pj9oUEqD1zTN=1lfuKh<=H(L zz}$4W$L^)&=v|DuLh^(}MHlbfVz*KfH$PW-RZMLF=UPX4k+|~T&T^K;>n~z8 z_KS>HJb7+WNjpqY2YQcwp6?~)(Mil(BKP+yan7E3z`xb%7cqe2fzjB zMBc;Nj%H5_0q%ueXayP*ZS$%E8-+So1gKxy(OoA%q zm}8)%r|_HQ2&te%QMqVpMd5h_O=`v2OMsRNDCZA&1Z{Fm+!~ zmM{$vUVcA|YZew&fOQeK0(6P)q&!zcDX|6_{GRMao}=kgBEL()SP`$+pJ1@?`0xCC z=iLi1&8c?Hv)Q)s_H-*>;cv^YNw(3OU=SZ0wI6=`_P_J-|LOO-$XgA>-R*efttR8T zFRI*otNY&Pe|^qFr@!|0^LGCu=j^SIE;)+*{);E8fczneNH6O~Zk$yWORgi3BAHfaQ$uk zZBDvrTqL>CbdidmW*q=&2q)@vPQ0M005`erpo7d4{?0OoN$R_nZ`;M-29>JQx`(=F5ci^D5 z$Ts(vLPeu%K(w>4B@Nm<#Q~70><4aH=L}(Pr?0mIZ)o$IR4N8|4^hAD^2qGl?Mq9X zTU#|C+5pJ3&OvPqXe-(laJLh2yF4N#Q&rT%k1olx`)IIo>v0;c9O<$I*Q z%|Qg%$_-20HH&SfJ{L_c+S}!;p~wZ|y6vnciWYd+(0~RXbM>5F(;Q}pMp78Gv!wY( zBN28F>S%#?4~|;`?;hUM&3oKu0^ahQucI0uOW@4!^W{9BHjRZWbf!wc6nSTIE-Ig$ z0ca}+o;^OiUgx5I6?seWb_F@_*;xTr$+0S7UzD&P@-uKWWIw2FYbFbEr3Peo>P%R8 zUR3#P7i|l)S=5l$C9U#3D+nsP zVTO8HeD$C)P~@#eW)JoEi|`pQfpL(lT@9(a0ZP8k(~i>&@c!~%Pvm`XzkTrB8ul0S z1bKC(f%?k>oI{@??uDYwbqi>ZK$i~#+CxOuDj}1yd=R190vC0A#A*(_yIBD7`X+Mv zn~1(Qtu3(4YXIBFv8`W?vD2z-E5~Ab9^x_qvlQEE@2Tipw0SUriyq53OOI2T z|8f97IjyPkMj|(LA1fi}TtXzCqRYt_kP;mp%lK$~X*$mRS^)r?nf zm0ddk>j2-5^rKD%EPMjrLN=J+gdq+*BPe;OeFal@|=XW#ouk1aU~ zL$g2Fwm*<=UHeJ-+@55oo~g7$4>a4eADsSw{`~*S8v*Zs9n1} z+;`mfy#Dq0@NDaS?%)35c|HDLKm5zszcK#T4_~?Q!AGx;oqn>$79G#Bt~2a-Kvcc5 zqd3?tF=SMpeF70o-S?<<1ROl1<3zqo3gb&@D= zJOkj*%#Wsi8*q)DJeHK|JEm1v8WDC)K)b6NreScy9)E)|u2$2H``V zhyhFp?f)_X@^I?FLs%539j9_US{S5JijdoZLVPaKG=MFKxJi8PbaLJKtoCNI(ks&H zk`-P&&*cHmDMZ!r08(k4kHvEF%P}dLNIxF>EuV-hg$W>vlUcRIGXu$ub27!S`c1|f zQHBs{*rG1zYqXKRTI*~qcagUz0?+4hnFH^mQfzzhT54xpkF|;5OArlzac#CH4e=e*goB z8mu;(0cbUSD1_;#pgy^-0KkFXRfJW}#2}#U;(Y>d*>IL>vLSaXXD~(85)!GqnQlwm zE%oKHY$X!+M3UsU0d&XVU*1Z9zzN=Gh!O1LZTZ4O8JG6Oqwq2%F?4_snI4b-_ z(dLiuLa-*$n)2R6)=s?UiM-u-a2rgK>cI8fHhYohUV=^+KKcG_J>iNF!9 zjUZSXVL#5pEGdNCZW!}|uOe?~^Kw|BPU@$xKUig(dMN|rcXlDJ>>=XrVd2$H>Z65} zkJ^T|7l%=D77yDKV%hA!It*qO&;aG!v1U)?{pCIO*_%i0h5P&Mqi5H#sPo6lSF|~{ zsB`^U6i@>VHScoWqRcsl3sg2HBNo70;5;Y_-6_pIw7ECmy|6pgiQP6X&9_Z!3T)$= zeA}?P&^80)0`bkz=u0rH>E1WRHT?*Ja+YXKKrY~uSNj~I;@JxUM}i$i(xRXG*VBOdI*FH>H-5HP5r2N!NDEmq#fLq!U zVH-OUsdYx-8BY(3O^KFMgJtkK&LafQ6-3%yo1(0GBxFpifE?f?VpHy0rENpxv$gP$ zIgE1O```0ey{f!d46S~L789b@mmiL`OJ5$aH@-dpU%|-VY&&L;K7Ycl|1jNJwuV^M z5UqctP8#$r<1O^sQ?+ z{=@j~FR%Z|YZ^SSCgi&HzV_XZfAze^(0%uq*VX&<^Dj`VfBmIhdih?f+%(Z0wJpem2&AV46(b;^! z0+>XmF+w@<)L%B_3P7+pfadX7DxUPS3$NtRGleo5a_O8Xn{Hr2MqysRYATIXJY`~=Z}E3!Wo-!Vsz%AP ztf3@=Jbexkq(n_AfN)h^GPUj~(dj%k83teq9mX;c%J~ITM@;0MO!uIqM8r`+)PzG@ z#*>4eNkfvH6ej!yE##;AY@nmcn#px5-`z}ZyKY`Cz@3XgDbHH5MD1%TA)+dzrn87q zXy;C@v3;95DGlqlquaY|*XmjXTy=JIw8>8IYP0M1w&5+_Lx1f7yv-NdL$@xqC+=N= zh-V|YFF+lXtJPPv1XFd(c~<%rA}Cd3ywF-+lceCN0|m@5Q#c z5AepALf}2zl!gy8B|SuvYlm}P=dwF7a=9JwzD=Sv6z4b6`t!s(H6~GXBYJwW?Oj`K z*Xq7M-eC7|54hV4VUZlbwQ?lQNSrl%O%nSqQLADUos%lX5swlpNDi z_?`(3n!&kzCG>wX z3)WZ`22rA1*-uG;e-hV48iq>gq@L0fsBfp^ngG6>{k`lkE@!6;?fUmqt>Z+f^&G{s zVG9%W`UtDvLpMPv^!7b!UPN@eH`)3ZQj%EZ0q<7;Z#5-R&ii>MSQBk700*QW`E^`U z^bKHNdKg2O@u>SnUVoWwUn63F0X6yu_#2qLWDlKgv~HN2LEQhAv}ITT*RmpSsm(>3 zR}p3RvdFsOP_=FBPq%rTzdeZCdMjCAvAFDIq0~nDsGIV<1suZ>9%sh+SSp`W!nQ+n z2RnC?$oulM}nJtcCY&KEC9p|lHe zE=-DsO2$$EIn{fs^P&Qj-=ooype+dyRG;rK{3sORD!7s7gp}W^`>x#tdr00}rCdrm zdUMmtn~N$}WUu$joJIYElMKC$=w6q-rGI%6omNR-%nAb>Ml8 zTqF4W=U|SNL!=(h=g(l#uP8o^s9QO8bwE=^kU+hdh<_99KiA$Ji=a*=xm=GUd^bEd zy0(U5?~QI}?7ROQI{w2}Uw`#SyYaq-cK2INw&k`|&dpF;c9uokzF2GFysg;~U?(1G zx4Q{~E)YTOj-o0|DD_!Px`ozdq-{oyWTM0=awLfx8F5f8w>V zF&V#m9BH0?^$`w6qAlCkh^=U#ts3p1R3R86lvulP-`zOYuqgQP2mAKBZ>)8w!WQ3* zSP0MZo)s0Y@ySG8M1Zy%oCG?65|quFgS{yyUo_=7>C_milTQqjXU9tV=r#bbs2Q(* zCe2#bVZyP=*G3;DN*W{@S`JOhq>#;u+Mm^RD!HF9fYyHkwunT`8I3q9L9xtPgvrLD zsn`c1EF%Ix@fZ{?b&FeX54J+e$_7S5tpFu)78AG>$8xI5AreW_gcU?7&$l;}eQPDvOPWPI2(OA3N5$fMSD%O+dDa>xdu&)Xa4T3bVz4RzO0 z7FLN^r-TW*kQ^v9Iso2Yol68>ipZncZoGOOwcJFXckQ#|J9=ouP-`c5blA~NMBbzI zcFnF9yXi=$-Fm9u9yqtq9=m-R_K~Zh5;veWheEu#3&GkUYP?aKzj4AodiPof-d}uj z8{qv6;O&jP-{JA|l<80^M$UUvPYKawgag>Yrep`+9RTy%h4jt6W219ny91XsuV_uJ zK~Jn!|*#OIqOsCO2&e|LRCLY_Yp;-a+lO@{bra z@ELp8)Z4+$Ew-#@7P7jV0nNUfJ>uoA#6z=pE|(Owh#x(@ofF^fDhQFJFpINhWZ+H!*^ z$qGdSKpJE&9jwYIMJo)ke(2yDqDZrG7k zXbD#1ez*~ZK4oGRn-NHD^sxm*;43%b**Ry%&>~Q7CgFZ@}BDGQXq<+tqq| z#wCDS!Zwds4QgHGWsi{aCQtr4;QcbMzxCt+vu_`?C(bmZ7ALwVYTi6jVBu7#1OQ%+ zHs@Ndrd?|Sdy z+Wmm{0RH6z+{#f+Wn?b;cB*r7_8qCzO2bwF-fNf4vUN)VaDn&=qHo6fmDILx$g?FF z$RyMCPAYRJ{v!1!c2Uik1kTDei{f@_bmp|4gTB_eT`rddC(Aj1MX~c!IubJ&uf$uajUCX!A&{xo3B5yU3X{OqU$5=>gTd;$u%%7JA$oaPqFtwj%f3bS@U!PFw4MV~pJfMbXt2tjnbyCo%vyS9*>p;)l8KheIyl)8B2;bn z`0!340xE{S8RP&LHT!X5;VDiu5Q!kJ9CLwW%u||=A!ZnX=4N#`Ln~}W_N+R6CJjW$V%oiPp{$_>RyCnptHt2E|2&n+O=tj|s$@rK1LBB`x9Re_X znKng~JmxEYQxL#RvEX2Cxgeq_J&9sL63m4i2o0G>R3hM5LW|Ry(ljW^6ssy`f|!$G z^%dEGHBonEjsxwU`XVT^QtNFhw!yYC+qZd;sJowhQLEi~c86VmWSJe`*+co&h#eno zwwsR)QroKLAl-K7HALQLhV7A?iBj)eY0q6)3yrvm$#AQ^b#b?S_|id2#g5^yb_$2J z>+H)v-0l&px#lGDHh&k|{3Uz%?oj}F7`1s3ktI%Ev(?eaL*aIy$lb7L9K6Xz-+?Op zRu_3w=S}2&Y;`uyN@1w95g)yyJNbCfxz7ac0YfzlLs z?^`{`9=Z|mKHU#^4-jDwB4ER_{rVwi!g24}VY^6w=aV=}4d4O3mONk+)b%dq%5U0b z_uRCZ9QjcCFtK@K6{38D_Dj0RQu~+o^{XN zbZQMCaMa!Zmgh#7(o4M~+g^C)EYEyFzvoixN2{?Ib#w)}&PJ5x%`6Pki0T*7Jm&Qm zpRpCx$Hx9(bx8rXq!+p621WTLAiTh~3))^q_#{n57l zzI@yEXu7RFmuXucM~Db8WDdYmY(!76l00$GB&!+4O>Ys$qjxF>93eJ1h@W_AHl<>( z*vn7up|co3jLDG1YEqSZqU>>1`8Y-HbzQqie*6;j`SSqx3xK;K@7JNrUnN)m{-whl z$P4z^^-Y{}h$Sig+BBSRG3Zd#SDOc2j>iqF~dVyykdYxz;c>1?0+Lr1c z`n(C&sAFE7^%K1>_?#J7FfX{V}NdRw|w>aV2ICu{uS{vz1b=To2H2JO# zr6|m$GDoPkuFN*BEwQazuy@5>F7|f>D+EC@K?&vDis4)p*oy8}(-8^PQlPd)jn4$= zV=)_2BwfCciXh5prSMkQG1Xz~eTv`}eG9jeF_yzkB=(t?r4wHS}3O1IRyO+Ae_p@yGA%Yh2lW`U${iG4T19f3$DD`?CY> zy(dR)PW>EPair3Qb`;qJE}Ghvad!OW44VMRN%1|8zOi8da2ghhMc5|FXr;W9h?$&r ze%ln=@koe`oP?IHWTlI^A%K%dVhO3;Rd!IZg@epOz=xAA9SUZKR)}0!S3-#;1K5G6 z)&nOIG2^PX;-Tr}GO#wRn`~1#!FOF#VI8A>wvx|Zbu85K=&_x_36;SK@_U@mq}ENt zfh`_@p2%uDJw4Ha@#ys!1#qHAOtu+*lbm6|R3FOGs40);HwwrF_^bMgs9`OnhBF76 zlYClEtd(U)BZdkg=Nihz9SogI!= z8=Yt>!MTMAydSz{6Q&fR%vE0wWzHhv?4d3@vNGEi(tEg&R7DlX?%;-MDD772p%Hu0 zf4Ut{?hbstI~hHyyM-?AC+Z%g^`UatN1(nRy=@)8XNf&=3-S1 zwd3e6RO8)Dt5FHok~l~9ZeQpw=;H@hx_;HER40s3Vz&jenHnNrfww61F6!k)g?G>P zvqRJ$_t9DF_e_LqsPjIuB*XTPBvI>~U`vU{mQpgbjNIr_Ch^6LB`_chTcBGJHz^8V z2=&@V{gr}zydSWfsM+_{`%!p z4!qUB+nKaD@CLZO;62``L@IMnjrSH96e4g9#B1_Jf9a8(_S%y>?3G7%+50a5-ro~> zpKYbYi&8~Ozc!=Uh$ROt+B}3*LjjTZ{Gu=zl6X6Jyveo=W?4Pp-Hw)^r38mHj#n>T z4%@gM`{pNd9OGCNlD5l3&%?aX0OhRXahj;z?2=vni@^J|J%3@){`3mq4I8OcgVgvv zFh<=(-hF_!hmDDGpV!BbFizD2{n9o#5QpvJ_%7i1FW@{_&<=RhnpdfzcGdk$0Lfyy zi|eP4oV!ZR7Qza&&gaXr_GjG`16?NF-IJ>q^}qVeRJ&>^B2P?tzWw1ZE|3%(?7el&@Hji{Wr%+Z>|WpVAHggA zd*_o&*0LW*#tpRN-95wF_xW4p@)-!$D3iTQ%MZQk3d(*g7!^!YAo_uP7i3Gac$Bn3E?GMRzhM zmmk2YNL%hcsTELD+p+DtCBjCIPO~=d*RS+LsSeGuiwR&C;L5>Hui(9acs6;M3?ifu zqTp;)$-cB*oIZ(FCHbaPkH^?yR#`jl3$&JXz$~Id?K$L#7Y1;b*=65v&nU5;#tk8%%J=yoye5N^`+2cO_{hmrP+=9 zT5QwcEb?t0s&pASvXxxat0^;C#wvU%llUs`j}FpFsV~hAqBuQD4e0edE9}nW&Gy9k z0ekVnDtr6VHv8nYqxRKDXNb5TvLC;Ho^$vkPTfz($5-~_ckkQtPabxZ`1-CAtILgZ zh*GAU_<-uFlkY``(tXWU3&$p#S5%c6xjsWfCt_A?H z!{fw-?|30}ZXuIcAtET%2G5o_3IS17PB0z1rrDmAMdZA>I8NcyeoepKb9#U%yw~nH ziSqpFE}rkP+m5%{ZHHU!=EIFR#$9Ls`uSt_)h8G1FCRTfD&Qe{l|Nt~ymgm-@a{## zdmC(!W8Ol9oXsNc_e?lNDZX}sdig_JyKNh){k4N-bn8l^^eezIB-tFxMA9vpfM6}a z*og0T0i}(~kCqa}td~71B|nu!{PlphO2uRyT2&Zp2e%GGg=e@ju@oZN!Il(i)VEVD zP{`}h*C_T??>!9dzPF!T;KDT0DpRZy;oPRSC_A(WTgC;+wxe4uAW2g+rSo}72;Z2L ztCFp`G6|L@kuGB_KIW1?uT5e9an3P`sH>O{K1|Zwt-KLVqPbSrrfZu-<(N_qc6Rq?W+s zg`3I3A&1n#g8hi8?oF~%ep~5^8Mfdif2-X!gDMW1$1o4fSwzVmE{$nuQ{r(@l)K`R zbJ2*V!fYO9F5i4}(O!CTFaG))q0AAhjf1wLZgmv9_`oJ8_3?4vM#>>KIe+s@`1wDD ztKg*z=sd`mzw(%bYg_HzXZM?Z_kcZpQ@iz+`db_2gqzW9Bp@&i=9&)Uw-<4oWdYn@ zmjHOT*!GbeYvwq&(pjyZ1*l9_8d<1mF(YdXrIh3iEqDrqK(2#2Un*Ft0PtfLH z-)mpIbHZM@uiyUk@)pY{h3LSW#Zb2cZ^}mL6xJhvCIatkiNoVu)Hw?SMt?I4Gt}k+ z?Gc!c5w7WBv>prTkT!&vZn!JWDp^d`@*V0an`|O2(m~ZlFUCjxrK(G(sS)XoiZXu( zb`pF!YiYn)-Mj?MY85KlT!5~~Rt-X?a?YsFw}fM=2cJbyr1CFC>QbPq{HtsQD)L=b z77M7WOiWcfYAPc8R7L2MINuaC7Ywi%g*IP9BjJDl)MQo-y(jI?nS{G#Vme6VLK%mWA6=>V< z&#=+E5^eSQRJ;AHRy#sk=>0Dq{vV6JCwKt(SHanT7)?7d+HcCq#;I{{j@y-wvAX&8 z2X9^YUw#p>{hgTYhrhaQzSDN&tO&?!br^kISRG468$(2!5>eQ;EctXHlJ@aw&~lS?SUwe%@ki7Z;>h<3=u= z6F6CIpe&6OKr2YqA1<{Hf}C&@ z;Mjw!*^s^X&`v1F{dV?1ogG?TV(U8))D8@r%S&kBR zxx_Xy41xY$S?5P2Np6uyvun0L?LldxQ5XL$_5Pjq*;{wm zmmfV~U;N<_#-sMdr;pj6KYPNy_|waj*WF5_6ki6S>(P%8OcR69;Bm^D;gpM>}=)L#ed+%KY6&1TEh!wFb z_TGCLbsVQW&TS?$IZMuKy`Qz6x$Z0Pd%yoY$ur53jmRMEeeB;_>$|>%={K6@q6T75 zH-xno_RGy&X-r;8(O*2}m2Dp9$HnedFV%-90>!5>g+-FG=S<|}T95{Rii<{62BVn| z2JHxim=}ssO`H<4n5Gk%BvXBmCIR2)_`+CsMfT*Y7M94(m`1CBYU}ygnt|<`iR*1l zHIa7mvb!-l=`FO74P1Q~ZtNg4dItt5yK3!Z57$5~_bX(y>>V_fM;1s*_bTxN8jtH^ zpW3rP%%hNl2GihFOp!pyKU45Um$_ZtcH}W|i`S>ckZoRIJvhzNv#U4|aGc8!9cFMi6W_f7LgPu}5lWMmN)x<)LxeP8`5XqzI)Z`~%$X7a;VYW+DU8Qp zcn)C@V|CgN#_srAeg_}e_nDaLZ0YdjFux}M_360$_!Pq0NQ(S;zgFCA7I5xp@rGrO zHEJ^Vi~9N7TFe4rn?nPuvOLxZyg!t!??gkZg3u_$fx>qh+fg8 z{TX8dwPZ4`kF4h=(~Mc;-dLKvQVQ`A_kdKX&SkpS)+u=+1$-inonD4%ngL0Rxaz(Q zT51L!-I1$%nR<$=bDHkH)wetnEw&C=niUaPIbIf0v^Dozcj(FtSJYAe-*6n%(zYWf zOT>#DvhVTDL57&rhi6cbZ15F3Toqm7Clgsp@#cE~OF*>0=)`UKS$NbvyfzU!S?f++ z+3$LuuVFybU=np?^954(uS2_$QRU3->pO62$sQ=3V zd+U_01LygtTcr8mDhX{P^$jkD2j_qDDH91OMFL%?Q#i?YN6_JK!qa~HBC37Md*|Q+*Yh1B7B}MO*6!O@-cZ~O8yoXAX|1P z-}2&Wmo`gxJKRvqc%v=kvSE^2xPopMqKY1F6DP2fW~y>y&tgp|iVg6(WBix=S-TwF z*?eIzE&y(NdfQLVjrxm^GmsO1RyV38bLPmZzcFP&N^>IMz!WnJa}%Nyi41hH$Ep&W zSQm=eZ1r2m%X~W1zArGiA8)=wWM0`!-nkUSG}4n<x1}*ao7D1WP z)&xS5h2%zCVmg9{wHJG4CamTH4+^EZ6vyDVOR86Mj)QEj^O4;h0kW;ZlOpadC%4DQ zsogQKvg76Ap=7ynGLwCGp**}^DbJYzzPno|KRjrM3EM6|J=rQB9+NP3cOwvV6IPBz zz|z^$nr%fG!!#y)c9{IU;jTtNjdb>eOH|SMH>8_myg;&22#87OazY$PYC^)50i>?k z!{uSK(9$yDV4RXlkq{Y@LsccKgeTUVk}7n%!BL!k2+PuMI@YokLhoj%Ld9qlxk>ztg{dJ0t)6bXflN z!+`wpez$ynzD<6Hl=q(f{%7|3C;Ag41_UNj32W@U?U*8*Sknx(g-Aca4qaK+(!tf1 zZY?cI7LczTWgiVuFY+62niUJkhS?!YYKtPHFqFl+L zQ;Uu5ub)0jYkew?daP`iNaNURrpKDY3&^1$T1bH(5IrQYM96B9f!NKiHKMtLFhwD# zz|5ixsaPvJ=vqi(IsCizsYcR+(MT5sv=jaJI*4NHvrS}O78iuEUW)35VpG{tY%W{T z(rzuIxbnD(;<^E2j19S#K=GC|1s0IY%=uoJq-0vl)=EeI4O8~jIGwTYj&;ZAWf3bS z^yCG&hh@NM&!J%`0-_IfBubOX)Sq}vPm4Kl2d3jbH=tk@hlK682XX5^Rd%3 zfp9ca0ve`EKpRbPqmUg>k?wVFC|l0%Nt3GwbL96A19JOVHc_0~Fiyem+*VexMwmaF^MXR*4NE>8 z4n`Wt(%_~rLNsh8lxaE^nPCt`I)s891!*=KbwL=by)ZuS&*xR&?N7n_mUBmmWn0=6 zHfQJk>v8$%`Bu4gJVic&Ji6KNyHf<6D8?E>6VLsU1i>xQXEv*zIb1YW-mva@PbiNg zog8enQB7DwvF9;~_lv%#lDLvtu_W=ec_H&9fHiq;qTu*LF%sXe(3z#Q2doWo*6hretEGC%F{%IIOPrfb|uWIT-$z*9hvlK}jPJwBY zkToKC%;DZ>o+UNItgE&xm5z(6WXn~~i&0zYIB6~y9~?Do)&I}e|MR+z!fRQ*^z&cA%_%f}R7Lu{xP;P-})4PX1$-~aLD^M75x z^06Vg>^I^^xqklbjr{QGrF{SCnSA_AmiXrv^6ZB(X&u5dc8`m+AF&l*ppbFgObdVx z!|NA{5pZNA9eQjt2Y%OFag3R2AT?M(q)^Y>#X#J8qp64ZOx{QuXP4BtNb38oWzpZi z>{4_R_pwLvVo5Ra!TTBbZ~6rGZS1lNNX1F(BqU%xT{W>T6PU{N$_TMU`-!OmyY6;$ z(j~Fh*b92d);d2~UrG#MhOKm|JgR1mtS@(z4HX!nFmd-)uaV9|EB3~YvZcmZ`Wu{N zAEpz7a1BPcN66TLWO;PH(vUU$^NStw`Q1VJjRd6Me;$>8em;#g>IHc=RxjuFM9Zxs z+0vA`7IPKk5?-?;fr5U7ec-i29E^M7Wo#fp&hLqn(=-gDTf*fC1{M36JbDUDIrJC6 z?#5k<;vEij9*n_B6ecaPIHhR;Pv%PUm`U2#Y)J*`OXqPXX-Fd*0%c#*8pv0!asZF% z;bs>ZZgiD{t?qKP%~g(ey2xmkvz+SYvCUS_bUMqE6S;EtXt6vvRw4IKmdUg8P4e=} zI(d7oOFpBwy};of%bq$)EliQFS%d0$SuBwM`aCNC_1iJ|$M-|>$M=Ksk9PwiGy#9S z9gzAITLY9F=CDXU+-Gw8`LI+bJIZWcdrc543!Ww!+*1LL6e4urkuFT#yvAjbrqY;U z&A&5Mrt$xqa-hb0%p$-9GZ!5CGO(Y_3t1o`On(}RqVgeS;-+yZI|9F!Xmlc`Ce7){ zYLJSh`@weRIm{*=R1IoUV3lXXCy4dnpoe@F4MRGL6;zz>99g45DBPoJw4x4b<{Y$o zeZzhDH!hWs8FNUhoNN}qM>Lc8EM$&O?5pSLJ^&#_H2lIzWil(X_N@(ahjD*POCinm{9( z&}-KxEs-VyT$&Sj&&My4IvCCMi3=dpErmq)<@0UHOQn;qZB1S#-ML0Gve6rD_7Vxf z|Go~+h6%spJc_qIFW;gHh&0F{jc^K#zz-eI1qyPkopS^dnoE{96jE0$FLIGaH`(YW@()w@sReaga>-4WdCkBEX&(}f* zHC=_e0R3m|{*pSTF`%03t&H@o_r}Y!f2KiJBSed#@$>K0wci;+wV;Cn|4;tb|Kxjp zA#i`We*E>h?09S`rXUIqJYO}ZAWqhT_OdQb*f33kQA+rhOp(qjC<7p`rT5R3zH1hE zp0AXOz2?%qE5-2dky>ql=D$4t`SJgN=>Pv0`2SPk7a8}fPP-Zl3Xx17_4-V){f&bB z>9@Cr&wshz84B+I{Fpm+Q;d|Mj__$p-($aJ`~Pzmy+;dm%r3ekLDC zBKyeW4?OQsHZl3vEmcg*dlhK@dkkG}Re5DKwxwdCFbu`r z2b)$83_N_@mr54U=L`sClfM31mUBosax=`-j*5@h9&TwI(z;-m(bA}Z8&y^w$s|pUc>fpIj zAHG1^U}1N02yaYVE!%U9WKX`aY))A$I|^6I5BES4u6N3#6P0q~V7^>r`Z|Nb%< ze)me*TJ3`lo2dzskQQ&1uqlLp|3rn{J60~&50=xgG|R6{>`x(~gaYBKaYq#{a`I20 z^!KNwBHfSJP@vIZ6DC$yDGQh`sW8NY_+7&ACHH}?n+ozE@&>b-bi*`c0Zh5Q*`Fc| z#La|Rh9^N|rlmnfmk%7CjMb(Vn~L~VvIvr60C0B&zh@Il4#PHEg!4zxn8;# zx8%>ok&DQpp2%~zgX5};Z|2MO)RjlbWX2v#I z(@snx9VBNBD0W-SzpVM~Jk zOibQ!G=s68I%rV5`MV?@Qz+swYk|o5d;$$#JfDka@=oCYD~nkK$#90uW$0PVd9-@w zctet$EpovPwWd;BFkO0gPLpq8_OE6jF@~@kIcGr`G<-y}yS;PJ_cJu9Bc@KoI<-5Y(y@-eCl3aozxVb*C|}3 zZ3^ovs;Vq^N2Y%zu{lyRiVlyHjStE9elSjkZnB>Em3sC0o&5NTC~HG8*BbX%LH|t$kt#CVm*cz%y5;*c=`Wdxj@4Dtqs7NLrmEHT|kFv~E1 z%9<+cZvdh1oF%n~;U)Dhll!mE8sK@I_$altTSTzW@BtaJ~C!pY4#U@HE$U&|Ug#NrJzCA?@FzT$+L0M}Y!9qEIXbtMFhNkoLs z1}<|44q1w%ESa5qZr4JX@92vUSV`Y84@3N1$Cgzxc5IEra)U;~0Jg<1eI>W|Y_2C@tfCNCE!a;I%qiWOeqo-Rl=(3LNli;;)@QixmAnYNG{*qh?< z&7|#2ETSXvnjU5x{w7RL=IpjfT#CoyFxLw?!27vIXhSjyf0F!<2fc8~Ay%AA9gnxpW{#ZX8cTI+G`_ zZ`aAM-))zFGI9Rn`+f5JyM6N8n*sUsa<_bXe?Z2D3#E`0pbDZ0tK-}x%xM)_#uO`s z9Fdb$#;=sF9BcBCO}We|G!Qu;%2iVAs>%FrNFa8RX)pk-aU6+ZVK|;ea>daABr>HY z@x78M+8OM_vzSVAyf~!zd>)1S2w@WEwnfM&aQTUiG$hD&&TR0PvwZ<_eoK&C+!`iV zcSgvq-BEJA-(PO*2>~MSk)I!Q$uCIYe#U_26GWlUFZzMZw=!LykgErB*>?l$q1Sg| znpB6iwH7nv)y*!s3!&*gto7FyYo#XMLgsNO*|28m?*fh^?|fH#lEHw-oP7EIra-kk#tgS(ec#CBvV{30_QK3DTtC`BD_Z6ppk^ z!!=U9^#XR~Fi)e=@lu~{?$2*QuXOKihaIPq?9@HX9jT%ZlfE=b4 z7}AY<3VCwASRS6v zliMe9D9V{~^>Bt;u+Natx(&+H77!#x2qM$x~G&v*uWigZYTmFT=z zEgvt7w3*?#52tu1qoXfkZ8Ef(z#raIu?z?aSfhsGq86|AsI0>hFcZ>ru^68Fj6hZi z_6vD_`F+@rXCve4fkPs4lj40;e$nDRk}AJYynW!eEaBf$E7@>+j9MVhCGxsOdoZZ5 z|Bl6BFNQ1D9e;I@4lU5}-tYxLycG3{qi`qiIfd4>*vGD&feZ_lIj?~^WLa}m1mB=4 zSU5{P_JP#H7fNWz1Sx5T2N8)e5zns$HqM$Kp~H!(AH^5AT33-aOw`w2tdWm@W`lRAlhd)UQPpSJifF z7>Z8m3aP(%SSSDaxDUsd-TqlLiH4H`nKYNC$fa3eW2_{{^O371m!c{hyB%cP;9F zUH@|j|Ns5*I4!hVV1G0eS&FTp;4%sS`apj8?J>pqv7zXGFkFue*ZW`Zli_`zucIjQ z@eiLL@HG!8!W3f)@!L;#fh8Q*uIc$(q$Zn2b|B9@D`D@gKMh-?a(nCmJgAS2{K zmK>rMm`f0GnC~RrrOvXKLbt2gLxw2QN3ef9u{nU9agdzur{L`g1KJLj(>ns>Ec*GA zTqkyg$*Da+=X+x0%0Z?TojlLh%HxX-@*IfyC7~tXzuX5?w}S7xM)o%PQ>fi(I#zIS zV=e*ImkOb*zs^Ip*Lz7Xaf`jhcG6wI?uRM8DT{`Jg5H3(GnPquErAtbw#0sN@JBIy z$56Zzn6{I+Qg!dmm5o1nHd945yS-d?-%XHy)~A`ux)k=hBtrF4$okmxZbfsv6(ZNJ zVhcHhX8Bk>sb)=9a<^SOJrCzFZ$dE;pDauW{%a>IygPD`S~> zeAv4?0&#koPm@otcK~soVSTVk@?vca0Tm%G%j6%Q_Hh8;mrgYFvpG1Dqpai~KlJmt zJJN}1iwBctq3>kL^o7jAu0DpneK5fyx<|itq)JW@T%Ph!(X=v%wDLh$ZQm0{vlZm`2 zkDnt4$F~p+hhmv7&DrZa5nrmdsVm`XDEm5x@2|xoir2_cR9smTKrqY#dJZH%JOGp> z1YN!-zUQv&zykx}IW)m=X5!A-y95c?1X;-Yb4@Z_jsRK2$+TsD@uOSJC1kqCyN|4zWgE*X+wBAx^Z-mH<3JEkGg3UOJC43FZ? z*Jk5Kg`AjtIlxHC8v+|q3w2TCoVyjcQ4a3s;M@CZ8GhW*EE|pVHR?E#lR!je* ziE`*ZyWSyCsKIe^`UfC&nnJJeX|m@A&1TP+eYj50s`v?HFRn>on*FVo)_UEzlq3K3 z%K?H~h78Tug@H(kiC-drr})pN0IRL37G?F;(1b|+8Eu*LeXIDsAgG>6giJBiO=nN9 zd-xfuP-60q==?&cQx?2i24*l<#>u1KY~=CV>+>%aH!U%XTP@=*DoMP6ms ze-(S(%a8j&(f#RnCg$HC%e!CiFd5&WINviA;J-e8ho|*-Xnf!E_yg92@6i0d`Q^Hy z5WoEK3V(M)-h8?)uYS2ofxgPe*W}Urb6jWn++}(B%O!dC)0jN{cwU}x-31oEgKXzM z-N>D{$K}e4opSO~gB*GmA}z;&g$W}_;DwLvWG@Cuz?`mdGLwor_?RQp(ic6l2nKS| zY%$|S^-Y5%4xBQ9&UFR_Ugcz#fw=3iZ!%^FuKC~QOyaZIQ(FSL1Y_rC@VjM4mE$)< zf`Ol#;$ZX=6Kc%VZ3eS+D*uLC`UD^tI#GpX_#S%m%vZ7+Hc*|m-HrJ$C>nS(P4 z(pZ|mDG6WJIlR~VgyD|D#rW9{WnF%@0+Jtb4HP!~% zPMjyCG-q=TJR`b!6DDwD3iWD?N$`8NUMW77Xq1`A*IF!wz_gUZahc)rLk}JXBRvGk zRtyC_&JEZOlY(*xZ8=1A7Kamim}5_Bl065V3s7SKP-Bo>I}j~*M-%12=`?wHnTh*W zDaIu=^6@?wB*EX_?~qSS;6FXvEFYfqF_Cx4TgYqYA+2Qx&yymERux2ohBA5kv)_vZ z_Krf963fo69u{%~(_}3n33d9Igi!~E9gV4`(j2ekrW^4xL&-SEer`{ZTPI~RR6D!hvfO~ zehG)w?Zo6>L}RFX>QEYvXnvnS)&XHmu7A88A}At}!*mj#pCZLPKWBEw!O~_$gqGYN z1Y%pG6I_8*h-j=zcs_D*%Tg}@Z;U=P$e}jF1QQe!$tTY{ht)(L0T>zVxwDz{(*2hp z34|aBOrA$!&!T{&0j1}|&i@wKofj@^9?0?5U}@@!?ph!1*t=^n_R{!DT*a&@TACha z%suJL4t6b{Uk>6BNpOqqyj50aL*8`^6QC=HwFw1XGtD*cTOrz5^w7(c;mrPAcix&v zX2zo}Dv?ke3GK2V`cxrNqJcIJpwVFm9??BZ7I5e;f_LDW4U!Q`Zu{V3iC8~f*4DEd z$Lrj!6lOji=o!1QQe988(P%D5Sp!|z6)l(cM$6^FSh+HkDA$Hlf9$hF$#*`-pRLpT^94I0y%1+o>-`!)nG z`hIrcBSXD%WOq5KV!qOedsz(4R4lxf2nu+d2EgDUpoKfu3pYTDw_2oz;1ys^@eWY7 zIrgR9bvk+9V)8y_AZpz?o-UuBH*-c9!#Wb~xVp{mMBsPCVK*uAzvZ&*zp(6TzSr*9JeTP5(^O-OS2$&uqDHZ%}T%I3@R9oo5oi zBv0O7mFFLCP{(e{vky1q#RpK}_qXKL$GZ%o_hj?gBx$}pO~Te;SBvZCd?)@5%((*K zU|6zJwIXIZsBwyHdj@-daHhEBOqBe+q@<0^!ANGj)E}NLyYIQnZIH{)O!P|7{&IaG ze*dM=QuUQy2IZH^w*ORs{k4bx%Lq*gc3Rl=&*R=O5o=NY{>GwctdDPhy-9Jt zY$(L9ez_vgf4nH){d!g2e!ecRaol?Pk>dRQX&%qY^PkRBpfAXaU(U;mPY{}ZJ}VF2 zkILP*$K>8ScGw?|%gr|jzg*u9%f+YrH}OUz=nrD2cf2t4MBesvXx`4Z$l z%N%GPI8@YiXb}XA*tW&8m_ojqD>jO|G)+~{%%k-(4lzdxvkG;G1na-IYABGBY9CYnP%K|qtCa&$w8 z>}l|p9#XM1I?nqq{*n(C}|@{aLXA?vHcDMsvCZI;NLYnwQ9pCeCd zlBJlKc-qcJCtL&ZXc&0CTgsA=4mr%lbuL-jAu}ZsERhUc9?hf{1HmW(Ih#&F)@G~m z&&N56eYhI9#AB;@lmingj z`-UQI4}|Wax^wwf#dsd@o}=NmWnZZYWww0m;)M}Tr?EKaPZJNwY0d?j4=@GI_bQ6U6q&=p z=$Ou=Sjmo^J#59uT(M%Z@+f5o%>K|km6cfIMDa&w>s!Ws)@3Ru_e9Bsow0IZN2Hv_ zo$GjCpd8zvNRzi5Ubj{buJe`y9Ud~==_!Nl?y|qdMfNp2%K+|igKbVS)ZrvsOHAd_ zg$ntO$xl}m;W(@G@~z9x)(0w@1_D^RpK5r72&)^KDP@ zj)CMD3yKv_bCk$hC6+7RhYNm)lGN1yKLn*l81FeXEeax<)eZTWt}X(3pZIXuLNfH? zd(DL_p?hv~8Zz~iH(r1$2mQJl=Ge!6C0pMRNedZxjmoSb?w);YE}Ku0D_=cMj@(@? z&;DU6=ifrg{7)ojdyuRFN_PUbo(B?Id0>iczOqu50BKun3Il36xidS6bE z$f`W-fM_6~LD&VIR!{PWVI!*b*Kak=~EB=zc?++Spp4)KonnwcCXDm73!*enj0c|dUrsn6u&8L-J0I*1B)ZjTMshteQJZs)wBnA@QN=kJ+2U;(sV6H&_yo~)A+HW3X>F|?rT8nWZ za3jJ}3PUU;+mDGij$Jc_tQz^)dM3waWK1n+!8`Mur7_J8*_V^FWjVo4caq*BfA-@+ ze4VfCX-whcaM@lPFGqVzq^}}Y20MV#54XtSjhXzv4tQ9*& z?48C0X9^2D43m_iaEf=vYN<$EMR*4Gnf$pR7MPskoy*r2@b46}gU$!K&I?^g0Rb|{ zO)J?Ohc))w_PhtyD7lP#&aa1z~@}0-muBO=6rJ9ND%4sqW*1G0_N3*}xsLimL1yVpF zmtnF7mbFdA$fR?MR2*C(!F3d8NNMKbTF}|KW6}}aGEq$R&yB>WvPaaWIS6wMUk-G8 z_$(<+i&+gh%8@R28R_wolN*piZeb0iYay=l@E*>C%3Q>J?8^cN5zbSH?OC?$_ z@SuKnp-3K$CLjLK80X4Dnx(3uKq<`+M)oyH9_(O3l;3e1yo~eU5avKh z5)835#&Nd6VNESj!`-n-WxpRsP)W2qjTC=I#9TYKHc>-c@Fa^#_qED(qg&r!CdbC;4No1T;O6i}vjRS+$N^Q2S0 z>cE}hOnBw8vxoJ*@5XYO!2R2EV~X5(=x!Le=0kq-r5QPKKVA-B*&w60x5}|=TV&*V zzns3kQ_kNXkSouI<=XQ@a`ovUx$){KUIND$D*I&La|?;!-*sn-wxfBp!>ZQ`xv+cw zB;bFDe(*Eg^T%V{ML`EivQ6N9N+EB)2uT{1gcDz%zICdMJgAcAAFj*WU+%Gb)^+q3 z!nTsQe};3W*Z(GcYeA>5Yf=B>`!9+vNaSA>Urg+OD#OOT_~o2Dp&&p0c#_9c^6-aI zE{gGwC%+WtA4lch_eX%ZkH}qEcMm=um4`nb=Ff-u_^{k&D!cc?FpvA?=DYoJ=llI~ z8JF&m}er z2SF@4*-S#{x&s1`0TuY~E~H#_v`d zYbS@AQ<>%pq%y%r08KpY^3KI7G$U*o1$r5KPb6Xx*OJ(IM`78S0Gz8N$TX(5vUqmar1zO}Un{db zlH!m^;D`gyt$Gt!(DbIV<_U9KU?8NKQ^5b4$~Esh6k?$XudgqWrdAa6mAt=PFd?#B zIz?trfN?x!BG$qjW>b(CLD*V6AJ;)lM;2qP>HuouN_M@4)f_3^h}V3bBL*Jx#V8Oz z|J2Enx{XF-V1ZbZ5c(gj#R$N(ol)DnkyZb<=vJ;# zgBwsGXDocRU=yel592&W!(4#Pvj`LMx5vX@t#&y#vS6Dxmb?C0yhk8~?t)Oq}mMh>?~vW$T$V@CKK!qla^rVvx&0qYnt;`E@~0)zF~;=E z4&0w28{EJnMKcLAtf+MprS~oU>edFD3x%!<>)!XFS+Z}T0^%lAE9mV>^MmhDkOGfXv zQ?$DwI^Z?U$HOp058iH(;ag3z=W>F-F;mdh@JwWdehY zGYvzF@ffApcQRiApY2-F62=D znu$(H7)#ij=su=^osuJO5@T*^6a2>~QS>M3#rK`dggzSwx8;~iELCvVS5sz?$+08^7%KsOS zJ!&))x5DMwSfr*?xQjwzr3WGzBhs)b#Q|ay1)C6;_DrU0ChR7zh7>z?(l&%h*pLhD zf`JR^VsO-3lv$G288ZD-12G34M`r7;csd1Jx{?_G|SI|gWVKpox&5q6sS8^nl5h;hak(j z2FI^ZI_+>f2vWN9bDjuk3aLf(GN~ebBOlkgRJ_8Iy{GW|V($pqDnD>JMlRUFVo;L{ zgkHq7UBq->9cF{h+FA;{&83baKGc#Udn=P=ppN*^&K%iXMz(oH6x#4iInh@Oir_>X z=+{7=NIkgE)FI9vWZ=6~J7fcaFEc;|+$ln*HW$h1t+jG`Q#EXR2mIx6a3c||IXQqr z?j;)vqA*H{LCekWN&;9C(jN7YPJl3$h~{=RlTdSNnCxoI7gsCbY@FciO(x6sngH2R z6A1at5+uYDbpamwIPLAN4M%_MCy}fQ=5VhBa{m^@u+xUDSDsAqrtz`n@OI|j_0j3r z2+sn3&N&oUwQf~iZv>F89SL@!?n}t=1`z^MjYI#v24zMZ`&nzIvn_|8hO=7{gwxuYl1UKD=1N~Q z->x7mVX`^jhmf4ryrv|)MRDJ!d6I1A!+Qv4H}1#0KudJ<)}TgfSSirxf(#|H_BqmD zBA=c#ODV-W2yVi1ig$BWfK-)-OFVG&GZYRYq#pi!L!R8)D%UWG8Om9~N{UI{9-^Q+ z(RnTAB1{J;J&|T87s*&4D>-}QaseFjQIG)(Qjr(x&Kpyb&u@0Hk57kt(aajnSSE0e z+48(cf-WkF(TAqch=Oj*d&L}IdNU?$^$M_3t62*0N>C0<(#ous!bRX~jQM+WUSm_% z9Oih7>v^V@!?rZRX29jfoRQ9W%R6HIYXrjhZ45?28^((Vl38`GEZm8vz6tpolCH{= z6XfM@1Z@25EuE)9qhN5C4v&|*GZQ5L2#|LxD9F~YFwUVc_hLrFPC2;`r42-}Xyjml z=&e^lI&-Ah7N6&LZv?g;{7Occ@Gt*lDNzh*DVru@05o6fX+m3ZV(g|V-BgJD5mKg_ zxCGYFSi%<90F>AyUoY=_+g0`+vgap5cFw*LZI<3VsZjxcj&h8}gccZCz!j9gx7`^|RuVt#r;b=VVSRa_{-kx($QCZ#J+p}wTF93z2Hn|mZ-vX&u z9E8k_tht)q_pw_WQ*ndLV^_RkH+=Cw|)R1i+FiqdlDf+_@#d(OLyjO0#-6Pk&+fAX~%``p0$2$xK z`zq7)751KEFE-1CmmB5ETXvuiyJYOuMmha-ot${wCg)zRq@B%T&tAbJoaOa7u&$WNiM813m0G<;F~cGzgigJ>{sStH#mu@ z;42EVciJ>rz+|Hn^b}ybuXy~L!|f}k1f96Gcb+D