From d3ba3ae69ef58ba4bdbbed49e86df06985d6ddc9 Mon Sep 17 00:00:00 2001 From: Nils Reichardt Date: Sat, 19 Oct 2024 12:21:04 +0200 Subject: [PATCH] Add user ID to the subject when pressing email support button (#1777) In case the `userId` is available, it will be appended to the subject. Part of #1069 --- app/lib/main/auth_app.dart | 2 + app/lib/main/sharezone_bloc_providers.dart | 2 + app/lib/support/support_page.dart | 20 ++-- app/lib/support/support_page_controller.dart | 36 ++++++- .../support/support_page_controller_test.dart | 68 ++++++++++++ .../support_page_controller_test.mocks.dart | 102 ++++++++++++++++++ .../support/support_page_test.mocks.dart | 27 ++++- .../support/support_page_test.mocks.dart | 27 ++++- 8 files changed, 265 insertions(+), 19 deletions(-) create mode 100644 app/test/settings/support/support_page_controller_test.mocks.dart diff --git a/app/lib/main/auth_app.dart b/app/lib/main/auth_app.dart index b14eafbf8..e5be4bf0f 100644 --- a/app/lib/main/auth_app.dart +++ b/app/lib/main/auth_app.dart @@ -29,6 +29,7 @@ import 'package:sharezone/support/support_page.dart'; import 'package:sharezone/legal/privacy_policy/privacy_policy_page.dart'; import 'package:sharezone/support/support_page_controller.dart'; import 'package:sharezone/util/cache/streaming_key_value_store.dart'; +import 'package:url_launcher_extended/url_launcher_extended.dart'; class AuthApp extends StatefulWidget { final Analytics analytics; @@ -69,6 +70,7 @@ class _AuthAppState extends State { hasPlusSupportUnlockedStream: Stream.value(false), isUserInGroupOnboardingStream: Stream.value(false), typeOfUserStream: Stream.value(null), + urlLauncher: UrlLauncherExtended(), ), ), ], diff --git a/app/lib/main/sharezone_bloc_providers.dart b/app/lib/main/sharezone_bloc_providers.dart index a8c577302..b2110bf01 100644 --- a/app/lib/main/sharezone_bloc_providers.dart +++ b/app/lib/main/sharezone_bloc_providers.dart @@ -133,6 +133,7 @@ import 'package:sharezone/util/platform_information_manager/flutter_platform_inf import 'package:sharezone/util/platform_information_manager/get_platform_information_retreiver.dart'; import 'package:sharezone_common/references.dart'; import 'package:stripe_checkout_session/stripe_checkout_session.dart'; +import 'package:url_launcher_extended/url_launcher_extended.dart'; import 'package:user/user.dart'; import '../holidays/holiday_bloc.dart'; @@ -378,6 +379,7 @@ class _SharezoneBlocProvidersState extends State { .hasFeatureUnlockedStream(SharezonePlusFeature.plusSupport), isUserInGroupOnboardingStream: signUpBloc.signedUp, typeOfUserStream: typeOfUserStream, + urlLauncher: UrlLauncherExtended(), ), ), StreamProvider.value( diff --git a/app/lib/support/support_page.dart b/app/lib/support/support_page.dart index fa0102481..829234d46 100644 --- a/app/lib/support/support_page.dart +++ b/app/lib/support/support_page.dart @@ -14,10 +14,9 @@ import 'package:provider/provider.dart'; import 'package:sharezone/navigation/logic/navigation_bloc.dart'; import 'package:sharezone/navigation/models/navigation_item.dart'; import 'package:sharezone/support/support_page_controller.dart'; -import 'package:sharezone_utils/launch_link.dart'; import 'package:sharezone/widgets/avatar_card.dart'; +import 'package:sharezone_utils/launch_link.dart'; import 'package:sharezone_widgets/sharezone_widgets.dart'; -import 'package:url_launcher/url_launcher.dart'; class SupportPage extends StatelessWidget { static const String tag = 'support-page'; @@ -232,17 +231,16 @@ class _FreeEmailTile extends StatelessWidget { semanticsLabel: 'E-Mail Icon', ), title: 'E-Mail', - subtitle: 'support@sharezone.net', + subtitle: freeSupportEmail, onPressed: () async { - final url = Uri.parse(Uri.encodeFull( - 'mailto:support@sharezone.net?subject=Ich brauche eure Hilfe! 😭')); try { - await launchUrl(url); + final controller = context.read(); + await controller.sendEmailToFreeSupport(); } on Exception catch (_) { if (!context.mounted) return; showSnackSec( context: context, - text: 'E-Mail: support@sharezone.net', + text: 'E-Mail: $freeSupportEmail', ); } }, @@ -256,7 +254,6 @@ class _PlusEmailTile extends StatelessWidget { @override Widget build(BuildContext context) { - const emailAddress = 'plus-support@sharezone.net'; return _SupportCard( icon: PlatformSvg.asset( 'assets/icons/email.svg', @@ -266,15 +263,14 @@ class _PlusEmailTile extends StatelessWidget { title: 'E-Mail', subtitle: 'Erhalte eine Rückmeldung innerhalb von wenigen Stunden.', onPressed: () async { - final url = Uri.parse(Uri.encodeFull( - 'mailto:$emailAddress?subject=[💎 Sharezone Plus Support] Meine Anfrage')); try { - await launchUrl(url); + final controller = context.read(); + await controller.sendEmailToPlusSupport(); } on Exception catch (_) { if (!context.mounted) return; showSnackSec( context: context, - text: 'E-Mail: $emailAddress', + text: 'E-Mail: $plusSupportEmail', ); } }, diff --git a/app/lib/support/support_page_controller.dart b/app/lib/support/support_page_controller.dart index 734831c1a..810c70654 100644 --- a/app/lib/support/support_page_controller.dart +++ b/app/lib/support/support_page_controller.dart @@ -10,8 +10,12 @@ import 'dart:async'; import 'package:common_domain_models/common_domain_models.dart'; import 'package:flutter/material.dart'; +import 'package:url_launcher_extended/url_launcher_extended.dart'; import 'package:user/user.dart'; +const freeSupportEmail = 'support@sharezone.net'; +const plusSupportEmail = 'plus-support@sharezone.net'; + class SupportPageController extends ChangeNotifier { bool get hasPlusSupportUnlocked => _hasSharezonePlus && _typeOfUser == TypeOfUser.student; @@ -30,6 +34,7 @@ class SupportPageController extends ChangeNotifier { late StreamSubscription _hasPlusSupportUnlockedSubscription; late StreamSubscription _isUserInGroupOnboardingSubscription; late StreamSubscription _typeOfUserSubscription; + final UrlLauncherExtended _urlLauncher; SupportPageController({ required Stream userIdStream, @@ -38,7 +43,8 @@ class SupportPageController extends ChangeNotifier { required Stream hasPlusSupportUnlockedStream, required Stream isUserInGroupOnboardingStream, required Stream typeOfUserStream, - }) { + required UrlLauncherExtended urlLauncher, + }) : _urlLauncher = urlLauncher { _userIdSubscription = userIdStream.listen((userId) { this.userId = userId; notifyListeners(); @@ -119,6 +125,34 @@ class SupportPageController extends ChangeNotifier { return url; } + Future sendEmailToFreeSupport() async { + await _openEmailApp( + email: freeSupportEmail, + subject: 'Meine Anfrage', + ); + } + + Future sendEmailToPlusSupport() async { + await _openEmailApp( + email: plusSupportEmail, + subject: '[💎 Plus Support] Meine Anfrage', + ); + } + + Future _openEmailApp({ + required String email, + + /// The subject of the email. + /// + /// The user ID is appended to the subject if it's not `null`. + required String subject, + }) async { + if (userId != null) { + subject += ' [User-ID: $userId]'; + } + await _urlLauncher.tryLaunchMailOrThrow(email, subject: subject); + } + bool _isPrivateAppleEmail(String email) { return email.endsWith('@privaterelay.appleid.com') || email == '-'; } diff --git a/app/test/settings/support/support_page_controller_test.dart b/app/test/settings/support/support_page_controller_test.dart index affafab04..04fa9668a 100644 --- a/app/test/settings/support/support_page_controller_test.dart +++ b/app/test/settings/support/support_page_controller_test.dart @@ -8,11 +8,23 @@ import 'package:common_domain_models/common_domain_models.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; import 'package:sharezone/support/support_page_controller.dart'; +import 'package:url_launcher_extended/url_launcher_extended.dart'; import 'package:user/user.dart'; +import 'support_page_controller_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) void main() { group(SupportPageController, () { + late MockUrlLauncherExtended urlLauncher; + + setUp(() { + urlLauncher = MockUrlLauncherExtended(); + }); + group('getVideoCallAppointmentsUnencodedUrlWithPrefills()', () { test('throws $UserNotAuthenticatedException when user Id is null', () async { @@ -23,6 +35,7 @@ void main() { hasPlusSupportUnlockedStream: Stream.value(false), isUserInGroupOnboardingStream: Stream.value(false), typeOfUserStream: Stream.value(null), + urlLauncher: urlLauncher, ); // Workaround to wait for stream subscription in constructor. @@ -42,6 +55,7 @@ void main() { hasPlusSupportUnlockedStream: Stream.value(false), isUserInGroupOnboardingStream: Stream.value(false), typeOfUserStream: Stream.value(null), + urlLauncher: urlLauncher, ); // Workaround to wait for stream subscription in constructor. @@ -63,6 +77,7 @@ void main() { hasPlusSupportUnlockedStream: Stream.value(true), isUserInGroupOnboardingStream: Stream.value(false), typeOfUserStream: Stream.value(TypeOfUser.parent), + urlLauncher: urlLauncher, ); // Workaround to wait for stream subscription in constructor. @@ -82,6 +97,7 @@ void main() { hasPlusSupportUnlockedStream: Stream.value(true), isUserInGroupOnboardingStream: Stream.value(false), typeOfUserStream: Stream.value(TypeOfUser.teacher), + urlLauncher: urlLauncher, ); // Workaround to wait for stream subscription in constructor. @@ -101,6 +117,7 @@ void main() { hasPlusSupportUnlockedStream: Stream.value(false), isUserInGroupOnboardingStream: Stream.value(false), typeOfUserStream: Stream.value(TypeOfUser.teacher), + urlLauncher: urlLauncher, ); // Workaround to wait for stream subscription in constructor. @@ -120,6 +137,7 @@ void main() { hasPlusSupportUnlockedStream: Stream.value(true), isUserInGroupOnboardingStream: Stream.value(false), typeOfUserStream: Stream.value(TypeOfUser.student), + urlLauncher: urlLauncher, ); // Workaround to wait for stream subscription in constructor. @@ -131,5 +149,55 @@ void main() { ); }); }); + + group('open email app', () { + test('free user', () async { + final controller = SupportPageController( + userIdStream: Stream.value(const UserId('userId123')), + userNameStream: Stream.value('My Cool Name'), + userEmailStream: Stream.value('my@email.com'), + hasPlusSupportUnlockedStream: Stream.value(false), + isUserInGroupOnboardingStream: Stream.value(false), + typeOfUserStream: Stream.value(TypeOfUser.student), + urlLauncher: urlLauncher, + ); + + // Workaround to wait for stream subscription in constructor. + await Future.delayed(Duration.zero); + + await controller.sendEmailToFreeSupport(); + + verify( + urlLauncher.tryLaunchMailOrThrow( + freeSupportEmail, + subject: "Meine Anfrage [User-ID: userId123]", + ), + ); + }); + + test('plus user', () async { + final controller = SupportPageController( + userIdStream: Stream.value(const UserId('userId123')), + userNameStream: Stream.value('My Cool Name'), + userEmailStream: Stream.value('my@email.com'), + hasPlusSupportUnlockedStream: Stream.value(true), + isUserInGroupOnboardingStream: Stream.value(false), + typeOfUserStream: Stream.value(TypeOfUser.student), + urlLauncher: urlLauncher, + ); + + // Workaround to wait for stream subscription in constructor. + await Future.delayed(Duration.zero); + + await controller.sendEmailToPlusSupport(); + + verify( + urlLauncher.tryLaunchMailOrThrow( + plusSupportEmail, + subject: "[💎 Plus Support] Meine Anfrage [User-ID: userId123]", + ), + ); + }); + }); }); } diff --git a/app/test/settings/support/support_page_controller_test.mocks.dart b/app/test/settings/support/support_page_controller_test.mocks.dart new file mode 100644 index 000000000..841b4a222 --- /dev/null +++ b/app/test/settings/support/support_page_controller_test.mocks.dart @@ -0,0 +1,102 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in sharezone/test/settings/support/support_page_controller_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:url_launcher/url_launcher.dart' as _i4; +import 'package:url_launcher_extended/src/url_launcher_extended.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [UrlLauncherExtended]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockUrlLauncherExtended extends _i1.Mock + implements _i2.UrlLauncherExtended { + @override + _i3.Future launchUrl( + Uri? url, { + _i4.LaunchMode? mode = _i4.LaunchMode.platformDefault, + _i4.WebViewConfiguration? webViewConfiguration = + const _i4.WebViewConfiguration(), + String? webOnlyWindowName, + }) => + (super.noSuchMethod( + Invocation.method( + #launchUrl, + [url], + { + #mode: mode, + #webViewConfiguration: webViewConfiguration, + #webOnlyWindowName: webOnlyWindowName, + }, + ), + returnValue: _i3.Future.value(false), + returnValueForMissingStub: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future tryLaunchOrThrow( + Uri? url, { + _i4.LaunchMode? mode = _i4.LaunchMode.platformDefault, + _i4.WebViewConfiguration? webViewConfiguration = + const _i4.WebViewConfiguration(), + String? webOnlyWindowName, + }) => + (super.noSuchMethod( + Invocation.method( + #tryLaunchOrThrow, + [url], + { + #mode: mode, + #webViewConfiguration: webViewConfiguration, + #webOnlyWindowName: webOnlyWindowName, + }, + ), + returnValue: _i3.Future.value(false), + returnValueForMissingStub: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future canLaunchUrl(Uri? url) => (super.noSuchMethod( + Invocation.method( + #canLaunchUrl, + [url], + ), + returnValue: _i3.Future.value(false), + returnValueForMissingStub: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future tryLaunchMailOrThrow( + String? address, { + String? subject, + String? body, + }) => + (super.noSuchMethod( + Invocation.method( + #tryLaunchMailOrThrow, + [address], + { + #subject: subject, + #body: body, + }, + ), + returnValue: _i3.Future.value(false), + returnValueForMissingStub: _i3.Future.value(false), + ) as _i3.Future); +} diff --git a/app/test/settings/support/support_page_test.mocks.dart b/app/test/settings/support/support_page_test.mocks.dart index d2fa0d745..ac7024631 100644 --- a/app/test/settings/support/support_page_test.mocks.dart +++ b/app/test/settings/support/support_page_test.mocks.dart @@ -3,7 +3,8 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:ui' as _i5; +import 'dart:async' as _i5; +import 'dart:ui' as _i6; import 'package:common_domain_models/common_domain_models.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; @@ -116,6 +117,26 @@ class MockSupportPageController extends _i1.Mock ), ) as String); + @override + _i5.Future sendEmailToFreeSupport() => (super.noSuchMethod( + Invocation.method( + #sendEmailToFreeSupport, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future sendEmailToPlusSupport() => (super.noSuchMethod( + Invocation.method( + #sendEmailToPlusSupport, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + @override void dispose() => super.noSuchMethod( Invocation.method( @@ -126,7 +147,7 @@ class MockSupportPageController extends _i1.Mock ); @override - void addListener(_i5.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i6.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #addListener, [listener], @@ -135,7 +156,7 @@ class MockSupportPageController extends _i1.Mock ); @override - void removeListener(_i5.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #removeListener, [listener], diff --git a/app/test_goldens/settings/support/support_page_test.mocks.dart b/app/test_goldens/settings/support/support_page_test.mocks.dart index 4d88b328d..e36083925 100644 --- a/app/test_goldens/settings/support/support_page_test.mocks.dart +++ b/app/test_goldens/settings/support/support_page_test.mocks.dart @@ -3,7 +3,8 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:ui' as _i5; +import 'dart:async' as _i5; +import 'dart:ui' as _i6; import 'package:common_domain_models/common_domain_models.dart' as _i3; import 'package:mockito/mockito.dart' as _i1; @@ -116,6 +117,26 @@ class MockSupportPageController extends _i1.Mock ), ) as String); + @override + _i5.Future sendEmailToFreeSupport() => (super.noSuchMethod( + Invocation.method( + #sendEmailToFreeSupport, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future sendEmailToPlusSupport() => (super.noSuchMethod( + Invocation.method( + #sendEmailToPlusSupport, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + @override void dispose() => super.noSuchMethod( Invocation.method( @@ -126,7 +147,7 @@ class MockSupportPageController extends _i1.Mock ); @override - void addListener(_i5.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i6.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #addListener, [listener], @@ -135,7 +156,7 @@ class MockSupportPageController extends _i1.Mock ); @override - void removeListener(_i5.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #removeListener, [listener],