From 325dd142f785fcf2258fa3e0ea17de48512b7487 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Thu, 25 Apr 2024 14:21:55 +0900 Subject: [PATCH 01/30] Streamelement oauth2 setup --- .../params/streamelements_auth_params.dart | 12 +- lib/src/core/utils/constants.dart | 2 +- .../stream_elements/se_credentials_dto.dart | 27 +++ .../streamelements_repository_impl.dart | 158 ++++++++++++++---- .../repositories/twitch_repository_impl.dart | 4 +- .../stream_elements/se_credentials.dart | 35 ++++ .../streamelements_repository.dart | 7 +- .../repositories/twitch_repository.dart | 4 - .../usecases/streamelements_usecase.dart | 4 +- .../events/streamelements_events.dart | 4 +- 10 files changed, 206 insertions(+), 51 deletions(-) create mode 100644 lib/src/data/entities/stream_elements/se_credentials_dto.dart create mode 100644 lib/src/domain/entities/stream_elements/se_credentials.dart diff --git a/lib/src/core/params/streamelements_auth_params.dart b/lib/src/core/params/streamelements_auth_params.dart index 69a41f79..92c78f38 100644 --- a/lib/src/core/params/streamelements_auth_params.dart +++ b/lib/src/core/params/streamelements_auth_params.dart @@ -8,8 +8,16 @@ class StreamelementsAuthParams { const StreamelementsAuthParams({ this.clientId = kStreamelementsAuthClientId, - this.redirectUri = 'https://irllink.com/streamelements/auth', + this.redirectUri = 'https://irllink.com/api/streamelements/auth', this.responseType = 'code', - this.scopes = 'activities:read activities:write tips:read', + this.scopes = 'tips:read ' + 'activities:read ' + 'loyalty:read ' + 'overlays:read ' + 'store:read ' + 'bot:read ' + 'session:read ' + 'contest:read ' + 'giveaway:read ', }); } diff --git a/lib/src/core/utils/constants.dart b/lib/src/core/utils/constants.dart index 3c05599d..2b31226e 100644 --- a/lib/src/core/utils/constants.dart +++ b/lib/src/core/utils/constants.dart @@ -4,6 +4,6 @@ const kTwitchAuthUrlPath = '/oauth2/authorize'; const kRedirectScheme = 'dev.lezd.www.irllink'; -const kStreamelementsAuthClientId = ''; +const kStreamelementsAuthClientId = '77746eebf069856d'; const kStreamelementsUrlBase = 'api.streamelements.com'; const kStreamelementsAuthPath = '/oauth2/authorize'; diff --git a/lib/src/data/entities/stream_elements/se_credentials_dto.dart b/lib/src/data/entities/stream_elements/se_credentials_dto.dart new file mode 100644 index 00000000..31404e7a --- /dev/null +++ b/lib/src/data/entities/stream_elements/se_credentials_dto.dart @@ -0,0 +1,27 @@ +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; + +class SeCredentialsDTO extends SeCredentials { + const SeCredentialsDTO({ + required super.accessToken, + required super.refreshToken, + required super.expiresIn, + required super.scopes, + }); + + @override + Map toJson() => { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'expiresIn': expiresIn, + 'scopes': scopes, + }; + + factory SeCredentialsDTO.fromJson(Map map) { + return SeCredentialsDTO( + accessToken: map['accessToken'] as String, + refreshToken: map['refreshToken'] as String, + expiresIn: map['expiresIn'] as int, + scopes: map['scopes'] as String, + ); + } +} diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index 1d83dc85..302d2acb 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -1,55 +1,142 @@ -// import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'dart:convert'; + import 'package:dio/dio.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'package:get_storage/get_storage.dart'; import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; +import 'package:irllink/src/core/utils/constants.dart'; import 'package:irllink/src/data/entities/stream_elements/se_activity_dto.dart'; +import 'package:irllink/src/data/entities/stream_elements/se_credentials_dto.dart'; import 'package:irllink/src/data/entities/stream_elements/se_me_dto.dart'; import 'package:irllink/src/data/entities/stream_elements/se_overlay_dto.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; -// import 'package:irllink/src/core/utils/constants.dart'; import 'package:irllink/src/domain/repositories/streamelements_repository.dart'; class StreamelementsRepositoryImpl extends StreamelementsRepository { @override - Future> login(StreamelementsAuthParams params) async { + Future> login( + StreamelementsAuthParams params) async { try { - // final url = Uri.https(kStreamelementsUrlBase, kStreamelementsAuthPath, { - // 'client_id': params.clientId, - // 'redirect_uri': params.redirectUri, - // 'response_type': params.responseType, - // 'scope': params.scopes, - // }); - - // final result = await FlutterWebAuth.authenticate( - // url: url.toString(), - // callbackUrlScheme: kRedirectScheme, - // preferEphemeral: true, - // ); - - // final code = Uri.parse(result).queryParameters['access_token']; - // final refreshToken = Uri.parse(result).queryParameters['refresh_token']; - // final expiresIn = Uri.parse(result).queryParameters['expires_in']; + Uri url = Uri.https(kStreamelementsUrlBase, kStreamelementsAuthPath, { + 'client_id': params.clientId, + 'redirect_uri': params.redirectUri, + 'response_type': params.responseType, + 'scope': params.scopes, + }); - return const DataSuccess(null); + String result = await FlutterWebAuth.authenticate( + url: url.toString(), + callbackUrlScheme: kRedirectScheme, + preferEphemeral: true, + ); + + String? accessToken = Uri.parse(result).queryParameters['access_token']; + String? refreshToken = Uri.parse(result).queryParameters['refresh_token']; + String? expiresIn = Uri.parse(result).queryParameters['expires_in']; + + dynamic tokenInfos = await validateToken(accessToken!); + final String scopes = tokenInfos['scopes'].join(' '); + + SeCredentials seCredentials = SeCredentialsDTO( + accessToken: accessToken, + refreshToken: refreshToken!, + expiresIn: int.parse(expiresIn ?? '0'), + scopes: scopes, + ); + + storeCredentials(seCredentials); + + return DataSuccess(seCredentials); } catch (e) { return const DataFailed("Unable to retrieve StreamElements token"); } } @override - Future> disconnect() { - // TODO: implement disconnect - throw UnimplementedError(); + Future> refreshAccessToken( + SeCredentials seCredentials, + ) async { + Response response; + Dio dio = Dio(); + try { + final remoteConfig = FirebaseRemoteConfig.instance; + await remoteConfig.fetchAndActivate(); + String apiRefreshTokenUrl = + remoteConfig.getString('irllink_refresh_se_token_url'); + + response = await dio.get( + apiRefreshTokenUrl, + queryParameters: {'refresh_token': seCredentials.refreshToken}, + ); + + SeCredentials newSeCredentials = SeCredentials( + accessToken: response.data['access_token'], + refreshToken: response.data['refresh_token'], + expiresIn: response.data['expires_in'], + scopes: seCredentials.scopes, + ); + storeCredentials(newSeCredentials); + + await validateToken(newSeCredentials.accessToken); + + return DataSuccess(newSeCredentials); + } on DioException catch (e) { + debugPrint(e.toString()); + return const DataFailed("Refresh encountered issues"); + } + } + + void storeCredentials(SeCredentials seCredentials) { + GetStorage box = GetStorage(); + String jsonTwitchData = jsonEncode(seCredentials); + box.write('seCredentials', jsonTwitchData); + } + + Future> validateToken(String accessToken) async { + try { + Response response; + Dio dio = Dio(); + dio.options.headers["authorization"] = "OAuth $accessToken"; + response = + await dio.get('https://api.streamelements.com/oauth2/validate'); + return DataSuccess(response.data); + } on DioException catch (e) { + return DataFailed( + "Unable to validate StreamElements token: ${e.message}"); + } + } + + @override + Future> disconnect(String accessToken) async { + GetStorage box = GetStorage(); + box.remove('streamelementsData'); + Dio dio = Dio(); + try { + await dio.post( + 'https://api.streamelements.com/oauth2/revoke', + queryParameters: { + 'client_id': kStreamelementsAuthClientId, + 'token': accessToken, + }, + ); + + return const DataSuccess(null); + } on DioException catch (e) { + return DataFailed("Unable to revoke StreamElements token: ${e.message}"); + } } @override Future replayActivity(String token, SeActivity activity) async { - var dio = Dio(); + Dio dio = Dio(); try { dio.options.headers["Authorization"] = "Bearer $token"; await dio.post( @@ -63,7 +150,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future>> getLastActivities( String token, String channel) async { - var dio = Dio(); + Dio dio = Dio(); Response response; List activities = []; try { @@ -100,7 +187,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future>> getOverlays( String token, String channel) async { - var dio = Dio(); + Dio dio = Dio(); List overlays = []; try { dio.options.headers["Authorization"] = "Bearer $token"; @@ -122,7 +209,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future> getMe(String token) async { - var dio = Dio(); + Dio dio = Dio(); late SeMe me; try { dio.options.headers["Authorization"] = "Bearer $token"; @@ -141,7 +228,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future nextSong(String token, String userId) async { - var dio = Dio(); + Dio dio = Dio(); try { dio.options.headers["Authorization"] = "Bearer $token"; await dio.post( @@ -154,7 +241,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future removeSong(String token, String userId, String songId) async { - var dio = Dio(); + Dio dio = Dio(); try { dio.options.headers["Authorization"] = "Bearer $token"; await dio.delete( @@ -167,7 +254,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future resetQueue(String token, String userId) async { - var dio = Dio(); + Dio dio = Dio(); try { dio.options.headers["Authorization"] = "Bearer $token"; await dio.delete( @@ -182,7 +269,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future>> getSongQueue( String token, String userId) async { List songs = []; - var dio = Dio(); + Dio dio = Dio(); try { dio.options.headers["Authorization"] = "Bearer $token"; Response response = await dio.get( @@ -210,7 +297,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future> getSongPlaying(String token, String userId) async { - var dio = Dio(); + Dio dio = Dio(); try { dio.options.headers["Authorization"] = "Bearer $token"; Response response = await dio.get( @@ -231,10 +318,11 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { return DataFailed(e.toString()); } } - + @override - Future updatePlayerState(String token, String userId, String state) async { - var dio = Dio(); + Future updatePlayerState( + String token, String userId, String state) async { + Dio dio = Dio(); try { dio.options.headers["Authorization"] = "Bearer $token"; await dio.post( diff --git a/lib/src/data/repositories/twitch_repository_impl.dart b/lib/src/data/repositories/twitch_repository_impl.dart index 69a04ef8..31f7033d 100644 --- a/lib/src/data/repositories/twitch_repository_impl.dart +++ b/lib/src/data/repositories/twitch_repository_impl.dart @@ -74,7 +74,7 @@ class TwitchRepositoryImpl extends TwitchRepository { await getTwitchUser(null, accessToken) .then((value) => twitchUser = value.data!); - final twitchData = TwitchCredentialsDTO( + TwitchCredentials twitchData = TwitchCredentialsDTO( accessToken: accessToken, idToken: idToken, refreshToken: refreshToken!, @@ -84,7 +84,6 @@ class TwitchRepositoryImpl extends TwitchRepository { scopes: scopes, ); - //save the twitch credentials on the smartphone setTwitchOnLocal(twitchData); return DataSuccess(twitchData); @@ -202,7 +201,6 @@ class TwitchRepositoryImpl extends TwitchRepository { } } - @override Future setTwitchOnLocal(TwitchCredentials twitchData) async { final box = GetStorage(); String jsonTwitchData = jsonEncode(twitchData); diff --git a/lib/src/domain/entities/stream_elements/se_credentials.dart b/lib/src/domain/entities/stream_elements/se_credentials.dart new file mode 100644 index 00000000..27f777b4 --- /dev/null +++ b/lib/src/domain/entities/stream_elements/se_credentials.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; + +class SeCredentials extends Equatable { + final String accessToken; + final String refreshToken; + final int expiresIn; + final String scopes; + + const SeCredentials({ + required this.accessToken, + required this.refreshToken, + required this.expiresIn, + required this.scopes, + }); + + Map toJson() => { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'expiresIn': expiresIn, + 'scopes': scopes, + }; + + @override + List get props { + return [ + accessToken, + refreshToken, + expiresIn, + scopes, + ]; + } + + @override + bool get stringify => true; +} diff --git a/lib/src/domain/repositories/streamelements_repository.dart b/lib/src/domain/repositories/streamelements_repository.dart index bfb536a7..642cdbf3 100644 --- a/lib/src/domain/repositories/streamelements_repository.dart +++ b/lib/src/domain/repositories/streamelements_repository.dart @@ -1,14 +1,17 @@ import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; abstract class StreamelementsRepository { - Future> login(StreamelementsAuthParams params); + Future> login(StreamelementsAuthParams params); - Future> disconnect(); + Future> refreshAccessToken(SeCredentials seCredentials); + + Future> disconnect(String accessToken); Future replayActivity(String token, SeActivity activity); diff --git a/lib/src/domain/repositories/twitch_repository.dart b/lib/src/domain/repositories/twitch_repository.dart index 92670070..bc473c14 100644 --- a/lib/src/domain/repositories/twitch_repository.dart +++ b/lib/src/domain/repositories/twitch_repository.dart @@ -19,10 +19,6 @@ abstract class TwitchRepository { Future> getTwitchFromLocal(); - Future setTwitchOnLocal( - TwitchCredentials twitchData, - ); - Future> logout( String accessToken, ); diff --git a/lib/src/domain/usecases/streamelements_usecase.dart b/lib/src/domain/usecases/streamelements_usecase.dart index 79d09f83..731d5ab2 100644 --- a/lib/src/domain/usecases/streamelements_usecase.dart +++ b/lib/src/domain/usecases/streamelements_usecase.dart @@ -14,8 +14,8 @@ class StreamelementsUseCase { return streamelementsRepository.login(params); } - Future> disconnect() { - return streamelementsRepository.disconnect(); + Future> disconnect(String accessToken) { + return streamelementsRepository.disconnect(accessToken); } Future replayActivity(String token, SeActivity activity) { diff --git a/lib/src/presentation/events/streamelements_events.dart b/lib/src/presentation/events/streamelements_events.dart index 7f343d00..a9ec9cd0 100644 --- a/lib/src/presentation/events/streamelements_events.dart +++ b/lib/src/presentation/events/streamelements_events.dart @@ -22,8 +22,8 @@ class StreamelementsEvents { return streamelementsUseCase.login(params: params); } - Future> disconnect() { - return streamelementsUseCase.disconnect(); + Future> disconnect(String accessToken) { + return streamelementsUseCase.disconnect(accessToken); } Future replayActivity(String token, SeActivity activity) { From 176087502d64ffe512cd298310873434c22b800c Mon Sep 17 00:00:00 2001 From: LezdCS Date: Thu, 25 Apr 2024 16:55:10 +0900 Subject: [PATCH 02/30] Refresh stream elements token --- lib/assets/streamelements/seLogo.png | Bin 0 -> 48387 bytes lib/src/bindings/home_bindings.dart | 9 + lib/src/bindings/settings_bindings.dart | 26 ++- .../params/streamelements_auth_params.dart | 12 +- .../streamelements_repository_impl.dart | 42 +++- .../streamelements_repository.dart | 2 + .../usecases/streamelements_usecase.dart | 11 + .../controllers/home_view_controller.dart | 27 ++- .../controllers/settings_view_controller.dart | 33 +-- lib/src/presentation/events/home_events.dart | 9 +- .../widgets/settings/stream_elements.dart | 192 ++++++------------ pubspec.yaml | 1 + 12 files changed, 196 insertions(+), 168 deletions(-) create mode 100644 lib/assets/streamelements/seLogo.png diff --git a/lib/assets/streamelements/seLogo.png b/lib/assets/streamelements/seLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..abcc665ed7a0c1f35dc751f75e3c7d0f6e6d382a GIT binary patch literal 48387 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 vB^e4>8AGLx4(); diff --git a/lib/src/bindings/settings_bindings.dart b/lib/src/bindings/settings_bindings.dart index 737c01fd..5ad944a3 100644 --- a/lib/src/bindings/settings_bindings.dart +++ b/lib/src/bindings/settings_bindings.dart @@ -2,26 +2,36 @@ import 'package:get/get_core/src/get_main.dart'; import 'package:get/get_instance/src/bindings_interface.dart'; import 'package:get/get_instance/src/extension_instance.dart'; import 'package:irllink/src/data/repositories/settings_repository_impl.dart'; +import 'package:irllink/src/data/repositories/streamelements_repository_impl.dart'; import 'package:irllink/src/data/repositories/twitch_repository_impl.dart'; import 'package:irllink/src/domain/usecases/settings_usecase.dart'; +import 'package:irllink/src/domain/usecases/streamelements_usecase.dart'; import 'package:irllink/src/domain/usecases/twitch_usecase.dart'; import 'package:irllink/src/presentation/controllers/settings_view_controller.dart'; import 'package:irllink/src/presentation/events/settings_events.dart'; +import 'package:irllink/src/presentation/events/streamelements_events.dart'; class SettingsBindings extends Bindings { @override void dependencies() { Get.lazyPut( () => SettingsViewController( - settingsEvents: SettingsEvents( - settingsUseCase: SettingsUseCase( - settingsRepository: SettingsRepositoryImpl(), + settingsEvents: SettingsEvents( + settingsUseCase: SettingsUseCase( + settingsRepository: SettingsRepositoryImpl(), + ), + twitchUseCase: TwitchUseCase( + twitchRepository: TwitchRepositoryImpl(), + ), ), - twitchUseCase: TwitchUseCase( - twitchRepository: TwitchRepositoryImpl(), - ), - ), - ), + streamelementsEvents: StreamelementsEvents( + streamelementsUseCase: StreamelementsUseCase( + streamelementsRepository: StreamelementsRepositoryImpl(), + ), + settingsUseCase: SettingsUseCase( + settingsRepository: SettingsRepositoryImpl(), + ), + )), ); } } diff --git a/lib/src/core/params/streamelements_auth_params.dart b/lib/src/core/params/streamelements_auth_params.dart index 92c78f38..8b760f41 100644 --- a/lib/src/core/params/streamelements_auth_params.dart +++ b/lib/src/core/params/streamelements_auth_params.dart @@ -8,16 +8,8 @@ class StreamelementsAuthParams { const StreamelementsAuthParams({ this.clientId = kStreamelementsAuthClientId, - this.redirectUri = 'https://irllink.com/api/streamelements/auth', + this.redirectUri = 'https://www.irllink.com/api/streamelements/auth', this.responseType = 'code', - this.scopes = 'tips:read ' - 'activities:read ' - 'loyalty:read ' - 'overlays:read ' - 'store:read ' - 'bot:read ' - 'session:read ' - 'contest:read ' - 'giveaway:read ', + this.scopes = 'tips:read activities:read overlays:read', }); } diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index 302d2acb..25add6a6 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -66,7 +66,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { ) async { Response response; Dio dio = Dio(); - try { + try { final remoteConfig = FirebaseRemoteConfig.instance; await remoteConfig.fetchAndActivate(); String apiRefreshTokenUrl = @@ -117,7 +117,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future> disconnect(String accessToken) async { GetStorage box = GetStorage(); - box.remove('streamelementsData'); + box.remove('seCredentials'); Dio dio = Dio(); try { await dio.post( @@ -147,6 +147,44 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { } } + @override + Future> getSeCredentialsFromLocal() async { + final box = GetStorage(); + var seCredentialsString = box.read('seCredentials'); + if (seCredentialsString != null) { + Map seCredentialsJson = jsonDecode(seCredentialsString); + + SeCredentials seCredentials = SeCredentialsDTO.fromJson(seCredentialsJson); + + StreamelementsAuthParams params = const StreamelementsAuthParams(); + + List paramsScopesList = params.scopes.split(' '); + paramsScopesList.sort((a, b) { + return a.compareTo(b); + }); + String paramsScopesOrdered = paramsScopesList.join(' '); + + List savedScopesList = seCredentials.scopes.split(' '); + savedScopesList.sort((a, b) { + return a.compareTo(b); + }); + String savedScopesOrdered = savedScopesList.join(' '); + + if (savedScopesOrdered != paramsScopesOrdered) { + disconnect(seCredentials.accessToken); + return const DataFailed("Scopes have been updated, please login again"); + } + + //refresh the access token to be sure the token is going to be valid after starting the app + await refreshAccessToken(seCredentials) + .then((value) => seCredentials = value.data!); + + return DataSuccess(seCredentials); + } else { + return const DataFailed("No Twitch Data in local storage"); + } + } + @override Future>> getLastActivities( String token, String channel) async { diff --git a/lib/src/domain/repositories/streamelements_repository.dart b/lib/src/domain/repositories/streamelements_repository.dart index 642cdbf3..c8afc6a7 100644 --- a/lib/src/domain/repositories/streamelements_repository.dart +++ b/lib/src/domain/repositories/streamelements_repository.dart @@ -13,6 +13,8 @@ abstract class StreamelementsRepository { Future> disconnect(String accessToken); + Future> getSeCredentialsFromLocal(); + Future replayActivity(String token, SeActivity activity); Future>> getLastActivities( diff --git a/lib/src/domain/usecases/streamelements_usecase.dart b/lib/src/domain/usecases/streamelements_usecase.dart index 731d5ab2..95fcf7b8 100644 --- a/lib/src/domain/usecases/streamelements_usecase.dart +++ b/lib/src/domain/usecases/streamelements_usecase.dart @@ -1,6 +1,7 @@ import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; @@ -14,6 +15,16 @@ class StreamelementsUseCase { return streamelementsRepository.login(params); } + Future> refreshAccessToken({required SeCredentials seCredentials}) { + return streamelementsRepository.refreshAccessToken(seCredentials); + } + + Future> getSeCredentialsFromLocal() { + return streamelementsRepository.getSeCredentialsFromLocal(); + } + + + Future> disconnect(String accessToken) { return streamelementsRepository.disconnect(accessToken); } diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index 53d959d4..b60b4154 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -2,10 +2,13 @@ import 'dart:async'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:irllink/src/data/repositories/streamelements_repository_impl.dart'; import 'package:irllink/src/domain/entities/chat/chat_message.dart'; import 'package:irllink/src/domain/entities/settings.dart'; import 'package:irllink/src/domain/entities/settings/chat_settings.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_credentials.dart'; +import 'package:irllink/src/domain/usecases/streamelements_usecase.dart'; import 'package:irllink/src/presentation/controllers/dashboard_controller.dart'; import 'package:irllink/src/presentation/controllers/obs_tab_view_controller.dart'; import 'package:irllink/src/presentation/controllers/store_controller.dart'; @@ -45,6 +48,7 @@ class HomeViewController extends GetxController RxList iOSAudioSources = [].obs; TwitchCredentials? twitchData; + SeCredentials? seCredentials; //chat input late TextEditingController chatInputController; @@ -83,14 +87,16 @@ class HomeViewController extends GetxController twitchData = Get.arguments[0]; - timerRefreshToken = Timer.periodic( - const Duration(seconds: 13000), - (Timer t) => homeEvents - .refreshAccessToken(twitchData: twitchData!) - .then((value) => { - if (value.error == null) {twitchData = value.data!} - }), - ); + timerRefreshToken = + Timer.periodic(const Duration(seconds: 13000), (Timer t) { + homeEvents.getSeCredentialsFromLocal().then((value) => { + if (value.error == null) {seCredentials = value.data!} + }); + + homeEvents.refreshAccessToken(twitchData: twitchData!).then((value) => { + if (value.error == null) {twitchData = value.data!} + }); + }); } await getSettings(); @@ -117,6 +123,9 @@ class HomeViewController extends GetxController settingsUseCase: SettingsUseCase( settingsRepository: SettingsRepositoryImpl(), ), + streamelementsUseCase: StreamelementsUseCase( + streamelementsRepository: StreamelementsRepositoryImpl(), + ), ), chatGroup: chatGroup, ), @@ -193,7 +202,7 @@ class HomeViewController extends GetxController } for (var temp in chatViews) { - if(temp.chatGroup.id == '1') { + if (temp.chatGroup.id == '1') { continue; } ChatView view = channels diff --git a/lib/src/presentation/controllers/settings_view_controller.dart b/lib/src/presentation/controllers/settings_view_controller.dart index 3e8269ad..038399c2 100644 --- a/lib/src/presentation/controllers/settings_view_controller.dart +++ b/lib/src/presentation/controllers/settings_view_controller.dart @@ -1,17 +1,21 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:irllink/routes/app_routes.dart'; +import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/presentation/controllers/home_view_controller.dart'; import 'package:irllink/src/presentation/controllers/store_controller.dart'; import 'package:irllink/src/presentation/controllers/tts_controller.dart'; import 'package:irllink/src/presentation/events/settings_events.dart'; +import 'package:irllink/src/presentation/events/streamelements_events.dart'; import '../../domain/entities/twitch/twitch_user.dart'; class SettingsViewController extends GetxController { - SettingsViewController({required this.settingsEvents}); + SettingsViewController( + {required this.settingsEvents, required this.streamelementsEvents}); final SettingsEvents settingsEvents; + final StreamelementsEvents streamelementsEvents; late TextEditingController alternateChannelChatController; late TextEditingController obsWebsocketUrlFieldController; @@ -90,6 +94,22 @@ class SettingsViewController extends GetxController { Get.offAllNamed(Routes.login); } + Future loginStreamElements() async { + StreamelementsAuthParams params = const StreamelementsAuthParams(); + await streamelementsEvents.login(params: params).then((value) { + if (value.error == null) { + //TODO: snackbar error + } else { + //TODO: snackbar success + // TODO: save in settings the credentials + } + }); + } + + void disconnectStreamElements() { + //TODO + } + void removeHiddenUser(userId) { List hiddenUsersIds = homeViewController.settings.value.hiddenUsersIds!; hiddenUsersIds.remove(userId); @@ -99,17 +119,6 @@ class SettingsViewController extends GetxController { homeViewController.settings.refresh(); } - void removeChatJoined(channel) { - // List channels = homeViewController.settings.value.chatSettings!.chatsJoined; - // channels.remove(channel); - // homeViewController.settings.value = homeViewController.settings.value - // .copyWith( - // chatSettings: homeViewController.settings.value.chatSettings - // ?.copyWith(chatsJoined: channels)); - saveSettings(); - homeViewController.settings.refresh(); - } - void addBrowserTab() { bool isValid = false; isValid = addBrowserTitleKey.currentState!.validate(); diff --git a/lib/src/presentation/events/home_events.dart b/lib/src/presentation/events/home_events.dart index 60427806..ad80f0c8 100644 --- a/lib/src/presentation/events/home_events.dart +++ b/lib/src/presentation/events/home_events.dart @@ -2,19 +2,22 @@ import 'package:dio/dio.dart'; import 'package:irllink/src/core/params/twitch_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/settings.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_credentials.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_poll.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_stream_infos.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_user.dart'; import 'package:irllink/src/domain/usecases/settings_usecase.dart'; +import 'package:irllink/src/domain/usecases/streamelements_usecase.dart'; import 'package:irllink/src/domain/usecases/twitch_usecase.dart'; import 'package:twitch_chat/twitch_chat.dart'; class HomeEvents { final TwitchUseCase twitchUseCase; final SettingsUseCase settingsUseCase; + final StreamelementsUseCase streamelementsUseCase; - HomeEvents({required this.twitchUseCase, required this.settingsUseCase}); + HomeEvents({required this.twitchUseCase, required this.settingsUseCase, required this.streamelementsUseCase}); Future> getTwitchFromLocal() { return twitchUseCase.getTwitchFromLocal(); @@ -41,6 +44,10 @@ class HomeEvents { return twitchUseCase.refreshAccessToken(twitchData: twitchData); } + Future> getSeCredentialsFromLocal() { + return streamelementsUseCase.getSeCredentialsFromLocal(); + } + Future> getStreamInfo( String accessToken, String broadcasterId) { return twitchUseCase.getStreamInfo(accessToken, broadcasterId); diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index 71646725..7303ef28 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -1,8 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:irllink/src/presentation/controllers/store_controller.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import '../../controllers/settings_view_controller.dart'; class StreamElements extends GetView { @@ -16,137 +14,79 @@ class StreamElements extends GetView { @override Widget build(BuildContext context) { - bool isSubscribed = Get.find().isSubscribed(); - return Obx( - () => Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'StreamElements', - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 18, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - margin: const EdgeInsets.only(left: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.tertiary, - borderRadius: BorderRadius.circular(20), - ), - child: Wrap( - children: [ - Text( - "Premium feature", - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 12, - ), - ), - const Icon( - CupertinoIcons.sparkles, - size: 12, - color: Colors.yellow, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 6), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all( - Radius.circular(8), + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'StreamElements', + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + fontSize: 18, ), - color: Theme.of(context).colorScheme.secondary, ), - child: Column( - children: [ - TextFormField( - controller: controller.streamElementsFieldController, - onChanged: (value) { - controller.homeViewController.settings.value = controller - .homeViewController.settings.value - .copyWith(streamElementsAccessToken: value); - controller.saveSettings(); - }, - obscureText: !controller.seJwtShow.value, - enabled: isSubscribed ? true : false, - decoration: InputDecoration( - isDense: true, - disabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Colors.grey[700]!, - ), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 7), - hintText: 'StreamElements Access Token', - labelText: isSubscribed - ? 'StreamElements Access Token' - : 'Subscribe to unlock this feature', - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.tertiary, - ), - suffixIcon: IconButton( - icon: Icon(controller.seJwtShow.value - ? Icons.visibility - : Icons.visibility_off), - color: Theme.of(context).primaryIconTheme.color, - onPressed: () { - controller.seJwtShow.value = - !controller.seJwtShow.value; - }, + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + margin: const EdgeInsets.only(left: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiary, + borderRadius: BorderRadius.circular(20), + ), + child: Wrap( + children: [ + Text( + "Premium feature", + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + fontSize: 12, ), ), - ), - const SizedBox(height: 6), - RichText( - textAlign: TextAlign.center, - text: TextSpan( - children: [ - TextSpan( - text: 'To get your Access Token ', - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - ), - ), - WidgetSpan( - child: InkWell( - onTap: () { - launchUrlString( - "https://streamelements.com/dashboard/account/channels", - mode: LaunchMode.externalApplication, - ); - }, - child: Text( - "click here", - style: TextStyle( - color: Theme.of(context).colorScheme.tertiary, - ), - ), - ), - ), - TextSpan( - text: - '. Then press "Show Secret" and copy your JWT Token!', - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - ), - ), - ], + const Icon( + CupertinoIcons.sparkles, + size: 12, + color: Colors.yellow, ), - ), - ], + ], + ), + ), + ], + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(8), ), + color: Theme.of(context).colorScheme.secondary, + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Image( + image: AssetImage("lib/assets/streamelements/seLogo.png"), + width: 30, + ), + const SizedBox( + width: 12, + ), + InkWell( + onTap: (() => {controller.loginStreamElements()}), + child: const Text( + 'Login with StreamElements', + style: TextStyle(fontSize: 16), + ), + ), + ], + ), + ], ), - ], - ), + ), + ], ); } } diff --git a/pubspec.yaml b/pubspec.yaml index b2dc8930..e9d8b2ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -114,6 +114,7 @@ flutter: - lib/assets/i18n/ - lib/assets/twitch/ - lib/assets/kick/badges/ + - lib/assets/streamelements/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. From 1dc85ea20bbf97b9090c073581f4c5773186a8c6 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Thu, 25 Apr 2024 17:19:27 +0900 Subject: [PATCH 03/30] remove jwt input for Streamelements --- lib/src/data/entities/settings_dto.dart | 6 -- lib/src/domain/entities/settings.dart | 8 --- .../controllers/home_view_controller.dart | 18 +++-- .../controllers/settings_view_controller.dart | 9 +-- .../streamelements_view_controller.dart | 5 +- lib/src/presentation/events/home_events.dart | 5 ++ .../widgets/settings/stream_elements.dart | 69 ++++++++++++++----- 7 files changed, 75 insertions(+), 45 deletions(-) diff --git a/lib/src/data/entities/settings_dto.dart b/lib/src/data/entities/settings_dto.dart index fdca98ce..e194201f 100644 --- a/lib/src/data/entities/settings_dto.dart +++ b/lib/src/data/entities/settings_dto.dart @@ -29,7 +29,6 @@ class SettingsDTO extends Settings { required bool isObsConnected, required String obsWebsocketUrl, required String obsWebsocketPassword, - required String streamElementsAccessToken, required List browserTabs, required List obsConnectionsHistory, required StreamElementsSettings streamElementsSettings, @@ -50,7 +49,6 @@ class SettingsDTO extends Settings { obsWebsocketUrl: obsWebsocketUrl, obsWebsocketPassword: obsWebsocketPassword, isObsConnected: isObsConnected, - streamElementsAccessToken: streamElementsAccessToken, browserTabs: browserTabs, obsConnectionsHistory: obsConnectionsHistory, streamElementsSettings: streamElementsSettings, @@ -73,7 +71,6 @@ class SettingsDTO extends Settings { 'isObsConnected': isObsConnected, 'obsWebsocketUrl': obsWebsocketUrl, 'obsWebsocketPassword': obsWebsocketPassword, - 'streamElementsAccessToken': streamElementsAccessToken, 'browserTabs': browserTabs, 'obsConnectionsHistory': obsConnectionsHistory, 'streamElementsSettings': streamElementsSettings?.toJson(), @@ -119,9 +116,6 @@ class SettingsDTO extends Settings { obsWebsocketPassword: map['obsWebsocketPassword'] != null ? map['obsWebsocketPassword'] as String : const Settings.defaultSettings().obsWebsocketPassword!, - streamElementsAccessToken: map['streamElementsAccessToken'] != null - ? map['streamElementsAccessToken'] as String - : const Settings.defaultSettings().streamElementsAccessToken!, browserTabs: map['browserTabs'] != null ? map['browserTabs'] as List : const Settings.defaultSettings().browserTabs!, diff --git a/lib/src/domain/entities/settings.dart b/lib/src/domain/entities/settings.dart index 1c5f5e6a..de5aad68 100644 --- a/lib/src/domain/entities/settings.dart +++ b/lib/src/domain/entities/settings.dart @@ -23,7 +23,6 @@ class Settings extends Equatable { final bool? isObsConnected; final String? obsWebsocketUrl; final String? obsWebsocketPassword; - final String? streamElementsAccessToken; final List? browserTabs; final List? obsConnectionsHistory; final StreamElementsSettings? streamElementsSettings; @@ -48,7 +47,6 @@ class Settings extends Equatable { required this.isObsConnected, required this.obsWebsocketUrl, required this.obsWebsocketPassword, - required this.streamElementsAccessToken, required this.browserTabs, required this.obsConnectionsHistory, required this.streamElementsSettings, @@ -93,7 +91,6 @@ class Settings extends Equatable { this.isObsConnected = false, this.obsWebsocketUrl = "", this.obsWebsocketPassword = "", - this.streamElementsAccessToken = "", this.browserTabs = const [], this.obsConnectionsHistory = const [], this.streamElementsSettings = const StreamElementsSettings( @@ -139,7 +136,6 @@ class Settings extends Equatable { 'isObsConnected': isObsConnected, 'obsWebsocketUrl': obsWebsocketUrl, 'obsWebsocketPassword': obsWebsocketPassword, - 'streamElementsAccessToken': streamElementsAccessToken, 'browserTabs': browserTabs, 'obsConnectionsHistory': obsConnectionsHistory, 'streamElementsSettings': streamElementsSettings?.toJson(), @@ -164,7 +160,6 @@ class Settings extends Equatable { isObsConnected, obsWebsocketUrl, obsWebsocketPassword, - streamElementsAccessToken, browserTabs, obsConnectionsHistory, streamElementsSettings, @@ -194,7 +189,6 @@ class Settings extends Equatable { bool? isObsConnected, String? obsWebsocketUrl, String? obsWebsocketPassword, - String? streamElementsAccessToken, List? browserTabs, List? obsConnectionsHistory, StreamElementsSettings? streamElementsSettings, @@ -215,8 +209,6 @@ class Settings extends Equatable { isObsConnected: isObsConnected ?? this.isObsConnected, obsWebsocketUrl: obsWebsocketUrl ?? this.obsWebsocketUrl, obsWebsocketPassword: obsWebsocketPassword ?? this.obsWebsocketPassword, - streamElementsAccessToken: - streamElementsAccessToken ?? this.streamElementsAccessToken, browserTabs: browserTabs ?? this.browserTabs, obsConnectionsHistory: obsConnectionsHistory ?? this.obsConnectionsHistory, diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index b60b4154..e4725965 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -7,6 +7,7 @@ import 'package:irllink/src/domain/entities/chat/chat_message.dart'; import 'package:irllink/src/domain/entities/settings.dart'; import 'package:irllink/src/domain/entities/settings/chat_settings.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_credentials.dart'; import 'package:irllink/src/domain/usecases/streamelements_usecase.dart'; import 'package:irllink/src/presentation/controllers/dashboard_controller.dart'; @@ -48,7 +49,10 @@ class HomeViewController extends GetxController RxList iOSAudioSources = [].obs; TwitchCredentials? twitchData; + + // StreamElements SeCredentials? seCredentials; + SeMe? seMe; //chat input late TextEditingController chatInputController; @@ -90,7 +94,15 @@ class HomeViewController extends GetxController timerRefreshToken = Timer.periodic(const Duration(seconds: 13000), (Timer t) { homeEvents.getSeCredentialsFromLocal().then((value) => { - if (value.error == null) {seCredentials = value.data!} + if (value.error == null) + { + seCredentials = value.data!, + homeEvents + .getSeMe(seCredentials!.accessToken) + .then((value) => { + if (value.error == null) {seMe = value.data!} + }), + }, }); homeEvents.refreshAccessToken(twitchData: twitchData!).then((value) => { @@ -141,9 +153,7 @@ class HomeViewController extends GetxController bool isSubscribed = Get.find().isSubscribed(); if ((twitchData == null && isSubscribed) || - isSubscribed && - settings.value.streamElementsAccessToken != null && - settings.value.streamElementsAccessToken!.isNotEmpty) { + isSubscribed && seCredentials != null) { streamelementsViewController = Get.find(); StreamelementsTabView streamelementsPage = const StreamelementsTabView(); tabElements.add(streamelementsPage); diff --git a/lib/src/presentation/controllers/settings_view_controller.dart b/lib/src/presentation/controllers/settings_view_controller.dart index 038399c2..ebe107f3 100644 --- a/lib/src/presentation/controllers/settings_view_controller.dart +++ b/lib/src/presentation/controllers/settings_view_controller.dart @@ -20,7 +20,6 @@ class SettingsViewController extends GetxController { late TextEditingController alternateChannelChatController; late TextEditingController obsWebsocketUrlFieldController; late TextEditingController obsWebsocketPasswordFieldController; - late TextEditingController streamElementsFieldController; late TextEditingController addBrowserTitleController; late TextEditingController addBrowserUrlController; late TextEditingController addHiddenUsernameController; @@ -53,7 +52,6 @@ class SettingsViewController extends GetxController { storeController = Get.find(); obsWebsocketUrlFieldController = TextEditingController(); - streamElementsFieldController = TextEditingController(); obsWebsocketPasswordFieldController = TextEditingController(); addBrowserTitleController = TextEditingController(); addBrowserUrlController = TextEditingController(); @@ -73,8 +71,6 @@ class SettingsViewController extends GetxController { homeViewController.settings.value.obsWebsocketUrl!; obsWebsocketPasswordFieldController.text = homeViewController.settings.value.obsWebsocketPassword!; - streamElementsFieldController.text = - homeViewController.settings.value.streamElementsAccessToken!; getUsernames(); } @@ -106,8 +102,9 @@ class SettingsViewController extends GetxController { }); } - void disconnectStreamElements() { - //TODO + Future disconnectStreamElements() async { + if( homeViewController.seCredentials == null) return; + await streamelementsEvents.disconnect(homeViewController.seCredentials!.accessToken); } void removeHiddenUser(userId) { diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index fc43abb6..5949ca46 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -64,11 +64,12 @@ class StreamelementsViewController extends GetxController } Future applySettings() async { - if (jwt != homeViewController.settings.value.streamElementsAccessToken!) { + if(homeViewController.seCredentials == null) return; + if (jwt != homeViewController.seCredentials!.accessToken) { socket?.dispose(); socket = null; activities.clear(); - jwt = homeViewController.settings.value.streamElementsAccessToken!; + jwt = homeViewController.seCredentials!.accessToken; streamelementsEvents.getMe(jwt).then((value) => { if (value.error == null) {handleGetMe(value.data!)} }); diff --git a/lib/src/presentation/events/home_events.dart b/lib/src/presentation/events/home_events.dart index ad80f0c8..64b1551f 100644 --- a/lib/src/presentation/events/home_events.dart +++ b/lib/src/presentation/events/home_events.dart @@ -3,6 +3,7 @@ import 'package:irllink/src/core/params/twitch_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/settings.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_credentials.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_poll.dart'; import 'package:irllink/src/domain/entities/twitch/twitch_stream_infos.dart'; @@ -48,6 +49,10 @@ class HomeEvents { return streamelementsUseCase.getSeCredentialsFromLocal(); } + Future> getSeMe(String token) { + return streamelementsUseCase.getMe(token); + } + Future> getStreamInfo( String accessToken, String broadcasterId) { return twitchUseCase.getStreamInfo(accessToken, broadcasterId); diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index 7303ef28..0b8f83de 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import '../../controllers/settings_view_controller.dart'; class StreamElements extends GetView { @@ -14,6 +15,7 @@ class StreamElements extends GetView { @override Widget build(BuildContext context) { + bool isLoggedIn = controller.homeViewController.seMe != null; return Column( children: [ Row( @@ -63,30 +65,59 @@ class StreamElements extends GetView { ), child: Column( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Image( - image: AssetImage("lib/assets/streamelements/seLogo.png"), - width: 30, - ), - const SizedBox( - width: 12, - ), - InkWell( - onTap: (() => {controller.loginStreamElements()}), - child: const Text( - 'Login with StreamElements', - style: TextStyle(fontSize: 16), + isLoggedIn + ? Column( + children: [ + profile( + controller.homeViewController.seMe!, + ), + InkWell( + onTap: (() => {controller.disconnectStreamElements()}), + child: const Text( + 'Logout', + style: TextStyle(fontSize: 16), + ), + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Image( + image: AssetImage( + "lib/assets/streamelements/seLogo.png"), + width: 30, + ), + const SizedBox( + width: 12, + ), + InkWell( + onTap: (() => {controller.loginStreamElements()}), + child: const Text( + 'Login with StreamElements', + style: TextStyle(fontSize: 16), + ), + ), + ], ), - ), - ], - ), ], ), ), ], ); } + + Widget profile(SeMe me) { + return Container( + child: Row( + children: [ + Image( + image: NetworkImage(me.avatar), + ), + Text(me.displayName), + ], + ), + ); + } } From 7ce582c68df65ea6e6bfd625af5e1ba84d780585 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Thu, 25 Apr 2024 17:35:24 +0900 Subject: [PATCH 04/30] idk --- .../presentation/controllers/chat_view_controller.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/presentation/controllers/chat_view_controller.dart b/lib/src/presentation/controllers/chat_view_controller.dart index 47bb910b..f215ecee 100644 --- a/lib/src/presentation/controllers/chat_view_controller.dart +++ b/lib/src/presentation/controllers/chat_view_controller.dart @@ -257,6 +257,11 @@ class ChatViewController extends GetxController } for (Channel kc in kickChannels) { + bool alreadyCreated = + kickChats.firstWhereOrNull((k) => k.username == kc.channel) != null; + if (alreadyCreated) { + return; + } createKickChat(kc); } @@ -369,11 +374,6 @@ class ChatViewController extends GetxController } Future createKickChat(Channel kc) async { - bool alreadyCreated = - kickChats.firstWhereOrNull((k) => k.username == kc.channel) != null; - if (alreadyCreated) { - return; - } KickChat kickChat = KickChat( kc.channel, onDone: () => {}, From 10751531dff0d8a0477e845c112df5d57b878365 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Thu, 25 Apr 2024 18:19:33 +0900 Subject: [PATCH 05/30] todo --- .../presentation/controllers/streamelements_view_controller.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 5949ca46..f060deec 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -96,6 +96,7 @@ class StreamelementsViewController extends GetxController Future login() async { StreamelementsAuthParams params = const StreamelementsAuthParams(); await streamelementsEvents.login(params: params); + //TODO: set SeCredentials and seMe in homeviewcontroller } void updatePlayerState(String state) { From e4407e73eacdb830f055cff339286047fcdb418e Mon Sep 17 00:00:00 2001 From: LezdCS Date: Sun, 19 May 2024 18:56:53 +0900 Subject: [PATCH 06/30] se refresh token --- .../streamelements_repository_impl.dart | 14 +++++++-- .../usecases/streamelements_usecase.dart | 1 - .../controllers/home_view_controller.dart | 30 +++++++++++-------- lib/src/presentation/events/home_events.dart | 6 ++++ .../widgets/settings/stream_elements.dart | 18 +++++------ 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index f6bb2f9c..6288339d 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -18,6 +18,7 @@ import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart' import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; +import 'package:irllink/src/core/utils/globals.dart' as globals; import 'package:irllink/src/domain/repositories/streamelements_repository.dart'; @@ -42,6 +43,10 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { String? accessToken = Uri.parse(result).queryParameters['access_token']; String? refreshToken = Uri.parse(result).queryParameters['refresh_token']; String? expiresIn = Uri.parse(result).queryParameters['expires_in']; + globals.talker?.debug(accessToken); + globals.talker?.debug(refreshToken); + globals.talker?.debug(expiresIn); + globals.talker?.info('StreamElements login successful'); dynamic tokenInfos = await validateToken(accessToken!); final String scopes = tokenInfos['scopes'].join(' '); @@ -54,6 +59,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { ); storeCredentials(seCredentials); + globals.talker?.info('StreamElements login successful'); return DataSuccess(seCredentials); } catch (e) { @@ -152,10 +158,13 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future> getSeCredentialsFromLocal() async { final box = GetStorage(); var seCredentialsString = box.read('seCredentials'); + globals.talker?.info(seCredentialsString); + if (seCredentialsString != null) { Map seCredentialsJson = jsonDecode(seCredentialsString); - SeCredentials seCredentials = SeCredentialsDTO.fromJson(seCredentialsJson); + SeCredentials seCredentials = + SeCredentialsDTO.fromJson(seCredentialsJson); StreamelementsAuthParams params = const StreamelementsAuthParams(); @@ -351,7 +360,8 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { } @override - Future updatePlayerState(String token, String userId, String state) async { + Future updatePlayerState( + String token, String userId, String state) async { var dio = initDio(); try { dio.options.headers["Authorization"] = "Bearer $token"; diff --git a/lib/src/domain/usecases/streamelements_usecase.dart b/lib/src/domain/usecases/streamelements_usecase.dart index 95fcf7b8..94d82303 100644 --- a/lib/src/domain/usecases/streamelements_usecase.dart +++ b/lib/src/domain/usecases/streamelements_usecase.dart @@ -22,7 +22,6 @@ class StreamelementsUseCase { Future> getSeCredentialsFromLocal() { return streamelementsRepository.getSeCredentialsFromLocal(); } - Future> disconnect(String accessToken) { diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index e4725965..87e09b1c 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -91,23 +91,29 @@ class HomeViewController extends GetxController twitchData = Get.arguments[0]; + homeEvents.getSeCredentialsFromLocal().then((value) => { + if (value.error == null) + { + seCredentials = value.data!, + homeEvents.getSeMe(seCredentials!.accessToken).then((value) => { + if (value.error == null) {seMe = value.data!} + }), + }, + }); + timerRefreshToken = Timer.periodic(const Duration(seconds: 13000), (Timer t) { - homeEvents.getSeCredentialsFromLocal().then((value) => { - if (value.error == null) - { - seCredentials = value.data!, - homeEvents - .getSeMe(seCredentials!.accessToken) - .then((value) => { - if (value.error == null) {seMe = value.data!} - }), - }, - }); - homeEvents.refreshAccessToken(twitchData: twitchData!).then((value) => { if (value.error == null) {twitchData = value.data!} }); + + if (seCredentials != null) { + homeEvents + .refreshSeAccessToken(seCredentials: seCredentials!) + .then((value) => { + if (value.error == null) {seCredentials = value.data!} + }); + } }); } await getSettings(); diff --git a/lib/src/presentation/events/home_events.dart b/lib/src/presentation/events/home_events.dart index 64b1551f..475c775b 100644 --- a/lib/src/presentation/events/home_events.dart +++ b/lib/src/presentation/events/home_events.dart @@ -45,6 +45,12 @@ class HomeEvents { return twitchUseCase.refreshAccessToken(twitchData: twitchData); } + Future> refreshSeAccessToken({ + required SeCredentials seCredentials, + }) { + return streamelementsUseCase.refreshAccessToken(seCredentials: seCredentials); + } + Future> getSeCredentialsFromLocal() { return streamelementsUseCase.getSeCredentialsFromLocal(); } diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index 0b8f83de..c618b9ad 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -92,7 +92,7 @@ class StreamElements extends GetView { const SizedBox( width: 12, ), - InkWell( + controller.homeViewController.seCredentials != null ? const Text('logged in') : InkWell( onTap: (() => {controller.loginStreamElements()}), child: const Text( 'Login with StreamElements', @@ -109,15 +109,13 @@ class StreamElements extends GetView { } Widget profile(SeMe me) { - return Container( - child: Row( - children: [ - Image( - image: NetworkImage(me.avatar), - ), - Text(me.displayName), - ], - ), + return Row( + children: [ + Image( + image: NetworkImage(me.avatar), + ), + Text(me.displayName), + ], ); } } From a1fdd7dbec8fba18ac73ed468308ccba7c47fa05 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Mon, 20 May 2024 15:53:42 +0900 Subject: [PATCH 07/30] Add talker logs + attempt fixing refresh token SE --- lib/src/core/resources/data_state.dart | 14 +++-- .../settings_repository_impl.dart | 2 +- .../streamelements_repository_impl.dart | 60 +++++++++++-------- .../repositories/twitch_repository_impl.dart | 10 ++-- 4 files changed, 50 insertions(+), 36 deletions(-) diff --git a/lib/src/core/resources/data_state.dart b/lib/src/core/resources/data_state.dart index 6442d66e..f9261a9d 100644 --- a/lib/src/core/resources/data_state.dart +++ b/lib/src/core/resources/data_state.dart @@ -1,17 +1,23 @@ +import 'package:irllink/src/core/utils/globals.dart' as globals; + abstract class DataState { final T? data; final String? error; - const DataState({ + DataState({ this.data, this.error, - }); + }) { + if(error != null) { + globals.talker?.error(error); + } + } } class DataSuccess extends DataState { - const DataSuccess(T data) : super(data: data); + DataSuccess(T data) : super(data: data); } class DataFailed extends DataState { - const DataFailed(String error) : super(error: error); + DataFailed(String error) : super(error: error); } diff --git a/lib/src/data/repositories/settings_repository_impl.dart b/lib/src/data/repositories/settings_repository_impl.dart index 2d255cd3..f83ce852 100644 --- a/lib/src/data/repositories/settings_repository_impl.dart +++ b/lib/src/data/repositories/settings_repository_impl.dart @@ -15,7 +15,7 @@ class SettingsRepositoryImpl extends SettingsRepository { SettingsDTO settings = SettingsDTO.fromJson(settingsJson); return DataSuccess(settings); } - return const DataSuccess(Settings.defaultSettings()); + return DataSuccess(const Settings.defaultSettings()); } @override diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index 6288339d..fb043fe9 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -40,30 +40,27 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { preferEphemeral: true, ); - String? accessToken = Uri.parse(result).queryParameters['access_token']; - String? refreshToken = Uri.parse(result).queryParameters['refresh_token']; - String? expiresIn = Uri.parse(result).queryParameters['expires_in']; - globals.talker?.debug(accessToken); - globals.talker?.debug(refreshToken); - globals.talker?.debug(expiresIn); + String accessToken = Uri.parse(result).queryParameters['access_token']!; + String refreshToken = Uri.parse(result).queryParameters['refresh_token']!; + int expiresIn = int.parse(Uri.parse(result).queryParameters['expires_in']!); globals.talker?.info('StreamElements login successful'); - dynamic tokenInfos = await validateToken(accessToken!); - final String scopes = tokenInfos['scopes'].join(' '); + DataState tokenInfos = await validateToken(accessToken); + final String scopes = tokenInfos.data['scopes'].join(' '); - SeCredentials seCredentials = SeCredentialsDTO( + SeCredentials seCredentials = SeCredentials( accessToken: accessToken, - refreshToken: refreshToken!, - expiresIn: int.parse(expiresIn ?? '0'), + refreshToken: refreshToken, + expiresIn: expiresIn, scopes: scopes, ); + globals.talker?.debug(seCredentials); - storeCredentials(seCredentials); - globals.talker?.info('StreamElements login successful'); + await storeCredentials(seCredentials); return DataSuccess(seCredentials); } catch (e) { - return const DataFailed("Unable to retrieve StreamElements token"); + return DataFailed("Unable to retrieve StreamElements token: $e"); } } @@ -78,33 +75,38 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { await remoteConfig.fetchAndActivate(); String apiRefreshTokenUrl = remoteConfig.getString('irllink_refresh_se_token_url'); + globals.talker?.debug('Refresh url: ', apiRefreshTokenUrl); response = await dio.get( apiRefreshTokenUrl, queryParameters: {'refresh_token': seCredentials.refreshToken}, ); + globals.talker?.debug('Refresh SE response: ', response.data); + SeCredentials newSeCredentials = SeCredentials( accessToken: response.data['access_token'], refreshToken: response.data['refresh_token'], expiresIn: response.data['expires_in'], scopes: seCredentials.scopes, ); - storeCredentials(newSeCredentials); + await storeCredentials(newSeCredentials); await validateToken(newSeCredentials.accessToken); return DataSuccess(newSeCredentials); } on DioException catch (e) { - debugPrint(e.toString()); - return const DataFailed("Refresh encountered issues"); + return DataFailed("Refresh SE token failed: ${e.message}"); } } - void storeCredentials(SeCredentials seCredentials) { + Future storeCredentials(SeCredentials seCredentials) async { GetStorage box = GetStorage(); - String jsonTwitchData = jsonEncode(seCredentials); - box.write('seCredentials', jsonTwitchData); + globals.talker?.info('Encoding SE credentials into a String'); + String jsonData = jsonEncode(seCredentials); + globals.talker?.info('Successfully encoded SE creds: $jsonData'); + await box.write('seCredentials', jsonData); + globals.talker?.info('SE creds saved in local'); } Future> validateToken(String accessToken) async { @@ -114,8 +116,10 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { dio.options.headers["authorization"] = "OAuth $accessToken"; response = await dio.get('https://api.streamelements.com/oauth2/validate'); + globals.talker?.info('Token validated: ${response.data}'); return DataSuccess(response.data); } on DioException catch (e) { + globals.talker?.error(e.message); return DataFailed( "Unable to validate StreamElements token: ${e.message}"); } @@ -135,7 +139,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { }, ); - return const DataSuccess(null); + return DataSuccess(null); } on DioException catch (e) { return DataFailed("Unable to revoke StreamElements token: ${e.message}"); } @@ -157,6 +161,8 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future> getSeCredentialsFromLocal() async { final box = GetStorage(); + globals.talker?.info('Getting SE creds from local storage'); + var seCredentialsString = box.read('seCredentials'); globals.talker?.info(seCredentialsString); @@ -166,32 +172,34 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { SeCredentials seCredentials = SeCredentialsDTO.fromJson(seCredentialsJson); + globals.talker?.info('Checking if Scopes changed.'); StreamelementsAuthParams params = const StreamelementsAuthParams(); - List paramsScopesList = params.scopes.split(' '); paramsScopesList.sort((a, b) { return a.compareTo(b); }); String paramsScopesOrdered = paramsScopesList.join(' '); - List savedScopesList = seCredentials.scopes.split(' '); savedScopesList.sort((a, b) { return a.compareTo(b); }); String savedScopesOrdered = savedScopesList.join(' '); - if (savedScopesOrdered != paramsScopesOrdered) { + globals.talker?.info('Scopes changed, user need to relogin to SE.'); disconnect(seCredentials.accessToken); - return const DataFailed("Scopes have been updated, please login again"); + return DataFailed("Scopes have been updated, please login again."); } + globals.talker?.info('Scopes are the same: OK'); //refresh the access token to be sure the token is going to be valid after starting the app await refreshAccessToken(seCredentials) .then((value) => seCredentials = value.data!); + + globals.talker?.info('SE token refreshed.'); return DataSuccess(seCredentials); } else { - return const DataFailed("No Twitch Data in local storage"); + return DataFailed("No SE Data in local storage"); } } diff --git a/lib/src/data/repositories/twitch_repository_impl.dart b/lib/src/data/repositories/twitch_repository_impl.dart index 65050ac0..8d6b60a6 100644 --- a/lib/src/data/repositories/twitch_repository_impl.dart +++ b/lib/src/data/repositories/twitch_repository_impl.dart @@ -91,7 +91,7 @@ class TwitchRepositoryImpl extends TwitchRepository { return DataSuccess(twitchData); } catch (e) { - return const DataFailed("Unable to retrieve Twitch Data from Auth"); + return DataFailed("Unable to retrieve Twitch Data from Auth: $e"); } } @@ -192,7 +192,7 @@ class TwitchRepositoryImpl extends TwitchRepository { String savedScopesOrdered = savedScopesList.join(' '); if (savedScopesOrdered != paramsScopesOrdered) { - return const DataFailed("Scopes have been updated, please login again"); + return DataFailed("Scopes have been updated, please login again."); } //refresh the access token to be sure the token is going to be valid after starting the app @@ -201,7 +201,7 @@ class TwitchRepositoryImpl extends TwitchRepository { return DataSuccess(twitchData); } else { - return const DataFailed("No Twitch Data in local storage"); + return DataFailed("No Twitch Data in local storage"); } } @@ -362,7 +362,7 @@ class TwitchRepositoryImpl extends TwitchRepository { }, data: jsonEncode(titleMap), ); - return const DataSuccess(null); + return DataSuccess(null); } on DioException catch (e) { return DataFailed(e.toString()); } @@ -394,7 +394,7 @@ class TwitchRepositoryImpl extends TwitchRepository { data: jsonEncode(body), ); - return const DataSuccess(""); + return DataSuccess(""); } on DioException catch (e) { return DataFailed(e.toString()); } From 4d4dfc161e923151a8c11b79f1373731fad458b8 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Mon, 20 May 2024 17:44:14 +0900 Subject: [PATCH 08/30] fix get seMe for oauth2 --- .../params/streamelements_auth_params.dart | 2 +- .../streamelements_repository_impl.dart | 24 +++++++------- .../usecases/streamelements_usecase.dart | 2 +- .../controllers/home_view_controller.dart | 2 ++ .../controllers/settings_view_controller.dart | 2 +- .../streamelements_view_controller.dart | 14 ++------ .../events/streamelements_events.dart | 3 +- .../widgets/settings/stream_elements.dart | 32 ++++++++++++++----- 8 files changed, 47 insertions(+), 34 deletions(-) diff --git a/lib/src/core/params/streamelements_auth_params.dart b/lib/src/core/params/streamelements_auth_params.dart index 8b760f41..9c15a7e1 100644 --- a/lib/src/core/params/streamelements_auth_params.dart +++ b/lib/src/core/params/streamelements_auth_params.dart @@ -10,6 +10,6 @@ class StreamelementsAuthParams { this.clientId = kStreamelementsAuthClientId, this.redirectUri = 'https://www.irllink.com/api/streamelements/auth', this.responseType = 'code', - this.scopes = 'tips:read activities:read overlays:read', + this.scopes = 'channel:read tips:read activities:read overlays:read', }); } diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index fb043fe9..7dda4fbc 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -75,7 +75,6 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { await remoteConfig.fetchAndActivate(); String apiRefreshTokenUrl = remoteConfig.getString('irllink_refresh_se_token_url'); - globals.talker?.debug('Refresh url: ', apiRefreshTokenUrl); response = await dio.get( apiRefreshTokenUrl, @@ -149,7 +148,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future replayActivity(String token, SeActivity activity) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; await dio.post( 'https://api.streamelements.com/kappa/v2/activities/${activity.channel}/${activity.id}/replay', ); @@ -209,8 +208,9 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { var dio = initDio(); Response response; List activities = []; + globals.talker?.debug(token); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; response = await dio.get( 'https://api.streamelements.com/kappa/v2/activities/$channel', queryParameters: { @@ -242,7 +242,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { var dio = initDio(); List overlays = []; try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/overlays/$channel', queryParameters: {'search': ' ', 'type': 'regular'}, @@ -263,12 +263,14 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { var dio = initDio(); late SeMe me; try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/channels/me', ); + globals.talker?.debug('response: ${response.data}'); me = SeMeDTO.fromJson(response.data); + globals.talker?.debug('me: $me'); return DataSuccess(me); } on DioException catch (e) { @@ -280,7 +282,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future nextSong(String token, String userId) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; await dio.post( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/skip', ); @@ -293,7 +295,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future removeSong(String token, String userId, String songId) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; await dio.delete( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/queue/$songId', ); @@ -306,7 +308,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future resetQueue(String token, String userId) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; await dio.delete( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/queue/', ); @@ -321,7 +323,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { List songs = []; var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/queue', ); @@ -348,7 +350,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future> getSongPlaying(String token, String userId) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/playing', ); @@ -372,7 +374,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { String token, String userId, String state) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "Bearer $token"; + dio.options.headers["Authorization"] = "oAuth $token"; await dio.post( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/player/$state', ); diff --git a/lib/src/domain/usecases/streamelements_usecase.dart b/lib/src/domain/usecases/streamelements_usecase.dart index 94d82303..d03cc5ae 100644 --- a/lib/src/domain/usecases/streamelements_usecase.dart +++ b/lib/src/domain/usecases/streamelements_usecase.dart @@ -11,7 +11,7 @@ class StreamelementsUseCase { final StreamelementsRepository streamelementsRepository; StreamelementsUseCase({required this.streamelementsRepository}); - Future> login({required StreamelementsAuthParams params}) { + Future> login({required StreamelementsAuthParams params}) { return streamelementsRepository.login(params); } diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index 87e09b1c..026429e8 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -32,6 +32,7 @@ import '../widgets/tabs/streamelements_tab_view.dart'; import '../widgets/web_page_view.dart'; import 'chat_view_controller.dart'; import 'package:irllink/src/domain/entities/chat/chat_message.dart' as entity; +import 'package:irllink/src/core/utils/globals.dart' as globals; class HomeViewController extends GetxController with GetTickerProviderStateMixin { @@ -96,6 +97,7 @@ class HomeViewController extends GetxController { seCredentials = value.data!, homeEvents.getSeMe(seCredentials!.accessToken).then((value) => { + globals.talker?.debug('seMe value: ', value.data), if (value.error == null) {seMe = value.data!} }), }, diff --git a/lib/src/presentation/controllers/settings_view_controller.dart b/lib/src/presentation/controllers/settings_view_controller.dart index ebe107f3..13ec09b5 100644 --- a/lib/src/presentation/controllers/settings_view_controller.dart +++ b/lib/src/presentation/controllers/settings_view_controller.dart @@ -97,7 +97,7 @@ class SettingsViewController extends GetxController { //TODO: snackbar error } else { //TODO: snackbar success - // TODO: save in settings the credentials + Get.back(); } }); } diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 1a792d2e..4188e751 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; @@ -70,10 +69,9 @@ class StreamelementsViewController extends GetxController socket?.dispose(); socket = null; activities.clear(); - jwt = homeViewController.seCredentials!.accessToken; - streamelementsEvents.getMe(jwt).then((value) => { - if (value.error == null) {handleGetMe(value.data!)} - }); + if(homeViewController.seMe != null) { + handleGetMe(homeViewController.seMe!); + } connectWebsocket(); } } @@ -94,12 +92,6 @@ class StreamelementsViewController extends GetxController .then((value) => currentSong.value = value.data!); } - Future login() async { - StreamelementsAuthParams params = const StreamelementsAuthParams(); - await streamelementsEvents.login(params: params); - //TODO: set SeCredentials and seMe in homeviewcontroller - } - void updatePlayerState(String state) { if (userSeProfile == null) return; streamelementsEvents.updatePlayerState(jwt, userSeProfile!.id, state); diff --git a/lib/src/presentation/events/streamelements_events.dart b/lib/src/presentation/events/streamelements_events.dart index a9ec9cd0..658339e3 100644 --- a/lib/src/presentation/events/streamelements_events.dart +++ b/lib/src/presentation/events/streamelements_events.dart @@ -1,6 +1,7 @@ import 'package:irllink/src/core/params/streamelements_auth_params.dart'; import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_credentials.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; @@ -18,7 +19,7 @@ class StreamelementsEvents { required this.settingsUseCase, }); - Future> login({required StreamelementsAuthParams params}) { + Future> login({required StreamelementsAuthParams params}) { return streamelementsUseCase.login(params: params); } diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index c618b9ad..a61e8487 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -15,7 +15,7 @@ class StreamElements extends GetView { @override Widget build(BuildContext context) { - bool isLoggedIn = controller.homeViewController.seMe != null; + bool isLoggedIn = controller.homeViewController.seCredentials != null; return Column( children: [ Row( @@ -71,12 +71,27 @@ class StreamElements extends GetView { profile( controller.homeViewController.seMe!, ), - InkWell( - onTap: (() => {controller.disconnectStreamElements()}), - child: const Text( - 'Logout', - style: TextStyle(fontSize: 16), - ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Image( + image: AssetImage( + "lib/assets/streamelements/seLogo.png"), + width: 30, + ), + const SizedBox( + width: 12, + ), + InkWell( + onTap: (() => + {controller.disconnectStreamElements()}), + child: const Text( + 'Logout', + style: TextStyle(fontSize: 16), + ), + ), + ], ), ], ) @@ -92,7 +107,7 @@ class StreamElements extends GetView { const SizedBox( width: 12, ), - controller.homeViewController.seCredentials != null ? const Text('logged in') : InkWell( + InkWell( onTap: (() => {controller.loginStreamElements()}), child: const Text( 'Login with StreamElements', @@ -113,6 +128,7 @@ class StreamElements extends GetView { children: [ Image( image: NetworkImage(me.avatar), + width: 40, ), Text(me.displayName), ], From 1b04b9dfc8a02701494d18e8dcfe961fefe507c8 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Mon, 20 May 2024 17:50:26 +0900 Subject: [PATCH 09/30] fix revoke token --- .../repositories/streamelements_repository_impl.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index 7dda4fbc..33b491d9 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -69,7 +69,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { SeCredentials seCredentials, ) async { Response response; - Dio dio = Dio(); + Dio dio = initDio(); try { final remoteConfig = FirebaseRemoteConfig.instance; await remoteConfig.fetchAndActivate(); @@ -111,7 +111,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future> validateToken(String accessToken) async { try { Response response; - Dio dio = Dio(); + Dio dio = initDio(); dio.options.headers["authorization"] = "OAuth $accessToken"; response = await dio.get('https://api.streamelements.com/oauth2/validate'); @@ -126,9 +126,8 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { @override Future> disconnect(String accessToken) async { - GetStorage box = GetStorage(); - box.remove('seCredentials'); - Dio dio = Dio(); + Dio dio = initDio(); + dio.options.headers["authorization"] = "OAuth $accessToken"; try { await dio.post( 'https://api.streamelements.com/oauth2/revoke', @@ -137,7 +136,8 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { 'token': accessToken, }, ); - + GetStorage box = GetStorage(); + box.remove('seCredentials'); return DataSuccess(null); } on DioException catch (e) { return DataFailed("Unable to revoke StreamElements token: ${e.message}"); From df96eb12021c3470eca02dc6fd1333bc84ff425a Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 02:00:58 +0900 Subject: [PATCH 10/30] fix SE controller --- .../controllers/streamelements_view_controller.dart | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 4188e751..41734b34 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -64,13 +64,14 @@ class StreamelementsViewController extends GetxController } Future applySettings() async { - if(homeViewController.seCredentials == null) return; + if (homeViewController.seCredentials == null) return; if (jwt != homeViewController.seCredentials!.accessToken) { + jwt = homeViewController.seCredentials!.accessToken; socket?.dispose(); socket = null; activities.clear(); - if(homeViewController.seMe != null) { - handleGetMe(homeViewController.seMe!); + if (homeViewController.seMe != null) { + handleGetMe(homeViewController.seMe!); } connectWebsocket(); } @@ -194,7 +195,6 @@ class StreamelementsViewController extends GetxController Future onError() async { isSocketConnected.value = false; globals.talker?.error('Error on StreamElements websocket'); - } Future onDisconnect() async { @@ -205,7 +205,6 @@ class StreamelementsViewController extends GetxController Future onAuthenticated(data) async { isSocketConnected.value = true; globals.talker?.info('Connected to StreamElements websocket'); - } void onAddSongQueue(data) { @@ -221,10 +220,10 @@ class StreamelementsViewController extends GetxController void onNextSong(data) { dynamic songData = data[0]["nextSong"]; - if(songData == {}) return; + if (songData == {}) return; SeSong song = SeSong.fromJson(songData); currentSong.value = song; - if(song.id != ''){ + if (song.id != '') { songRequestQueue.removeAt(0); } } From e36722e1678efa9f36eb370325d814c7285fb750 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 02:59:53 +0900 Subject: [PATCH 11/30] fix streamelements --- lib/src/core/utils/twitch_event_sub.dart | 2 +- .../streamelements_repository_impl.dart | 7 +----- .../controllers/home_view_controller.dart | 22 ++++++++--------- .../streamelements_view_controller.dart | 24 +++++++++---------- 4 files changed, 25 insertions(+), 30 deletions(-) diff --git a/lib/src/core/utils/twitch_event_sub.dart b/lib/src/core/utils/twitch_event_sub.dart index 0a0c7ade..4661d882 100644 --- a/lib/src/core/utils/twitch_event_sub.dart +++ b/lib/src/core/utils/twitch_event_sub.dart @@ -139,7 +139,7 @@ class TwitchEventSub { } void _onDone() { - globals.talker?.debug("Twitch Sub Event: Connection closed"); + globals.talker?.info("Twitch Sub Event: Connection closed"); close(); } diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index 33b491d9..be07f1b0 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -54,7 +54,6 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { expiresIn: expiresIn, scopes: scopes, ); - globals.talker?.debug(seCredentials); await storeCredentials(seCredentials); @@ -81,8 +80,6 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { queryParameters: {'refresh_token': seCredentials.refreshToken}, ); - globals.talker?.debug('Refresh SE response: ', response.data); - SeCredentials newSeCredentials = SeCredentials( accessToken: response.data['access_token'], refreshToken: response.data['refresh_token'], @@ -208,7 +205,6 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { var dio = initDio(); Response response; List activities = []; - globals.talker?.debug(token); try { dio.options.headers["Authorization"] = "oAuth $token"; response = await dio.get( @@ -267,10 +263,9 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/channels/me', ); - globals.talker?.debug('response: ${response.data}'); me = SeMeDTO.fromJson(response.data); - globals.talker?.debug('me: $me'); + globals.talker?.debug('SE me: $me'); return DataSuccess(me); } on DioException catch (e) { diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index 026429e8..8e0eea37 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/data/repositories/streamelements_repository_impl.dart'; import 'package:irllink/src/domain/entities/chat/chat_message.dart'; import 'package:irllink/src/domain/entities/settings.dart'; @@ -32,7 +33,6 @@ import '../widgets/tabs/streamelements_tab_view.dart'; import '../widgets/web_page_view.dart'; import 'chat_view_controller.dart'; import 'package:irllink/src/domain/entities/chat/chat_message.dart' as entity; -import 'package:irllink/src/core/utils/globals.dart' as globals; class HomeViewController extends GetxController with GetTickerProviderStateMixin { @@ -92,16 +92,16 @@ class HomeViewController extends GetxController twitchData = Get.arguments[0]; - homeEvents.getSeCredentialsFromLocal().then((value) => { - if (value.error == null) - { - seCredentials = value.data!, - homeEvents.getSeMe(seCredentials!.accessToken).then((value) => { - globals.talker?.debug('seMe value: ', value.data), - if (value.error == null) {seMe = value.data!} - }), - }, - }); + DataState seCreds = + await homeEvents.getSeCredentialsFromLocal(); + if (seCreds.error == null) { + seCredentials = seCreds.data; + DataState seMeResult = + await homeEvents.getSeMe(seCredentials!.accessToken); + if (seMeResult.error == null) { + seMe = seMeResult.data; + } + } timerRefreshToken = Timer.periodic(const Duration(seconds: 13000), (Timer t) { diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 41734b34..1a6030e7 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -64,6 +64,7 @@ class StreamelementsViewController extends GetxController } Future applySettings() async { + globals.talker?.info('Applying StreamElements settings'); if (homeViewController.seCredentials == null) return; if (jwt != homeViewController.seCredentials!.accessToken) { jwt = homeViewController.seCredentials!.accessToken; @@ -85,9 +86,9 @@ class StreamelementsViewController extends GetxController streamelementsEvents .getLastActivities(jwt, me.id) .then((value) => activities.value = value.data!); - streamelementsEvents - .getSongQueue(jwt, me.id) - .then((value) => songRequestQueue.value = value.data!); + streamelementsEvents.getSongQueue(jwt, me.id).then((value) => { + if (value.error == null) {songRequestQueue.value = value.data!} + }); streamelementsEvents .getSongPlaying(jwt, me.id) .then((value) => currentSong.value = value.data!); @@ -115,18 +116,17 @@ class StreamelementsViewController extends GetxController /// Connect to WebSocket Future connectWebsocket() async { - socket = io( - 'https://realtime.streamelements.com', - OptionBuilder() - .setTransports(['websocket']) // for Flutter or Dart VM - .disableAutoConnect() - .build()); - socket!.connect(); + socket = io('https://realtime.streamelements.com', + OptionBuilder().setTransports(['websocket']) + // .disableAutoConnect() + .build() + ); + + // socket!.connect(); socket!.on('connect_error', (data) => onError()); socket!.on('connect', (data) => onConnect()); socket!.on('disconnect', (data) => onDisconnect()); socket!.on('authenticated', (data) => onAuthenticated(data)); - socket!.on( 'event:test', (data) => { @@ -189,7 +189,7 @@ class StreamelementsViewController extends GetxController } Future onConnect() async { - socket?.emit('authenticate', {"method": 'jwt', "token": jwt}); + socket?.emit('authenticate', {"method": 'oauth2', "token": jwt}); } Future onError() async { From b3bee7fd75280d03e77cee31cfa7d0861f631f68 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 11:28:16 +0900 Subject: [PATCH 12/30] SE oauth2 progress --- lib/src/core/utils/init_dio.dart | 6 +-- .../controllers/home_view_controller.dart | 37 ++++++++++------ .../controllers/settings_view_controller.dart | 44 ++++++++++++++++--- .../streamelements_view_controller.dart | 6 +-- .../widgets/settings/stream_elements.dart | 12 ++--- 5 files changed, 74 insertions(+), 31 deletions(-) diff --git a/lib/src/core/utils/init_dio.dart b/lib/src/core/utils/init_dio.dart index 970eb610..71ee4649 100644 --- a/lib/src/core/utils/init_dio.dart +++ b/lib/src/core/utils/init_dio.dart @@ -1,5 +1,4 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart'; import 'package:talker_dio_logger/talker_dio_logger_settings.dart'; import 'package:irllink/src/core/utils/globals.dart' as globals; @@ -11,8 +10,9 @@ Dio initDio() { TalkerDioLogger( talker: talker, settings: TalkerDioLoggerSettings( - requestFilter: (RequestOptions options) => kDebugMode ? true : !options.path.contains('api.twitch.tv'), - responseFilter: (response) => kDebugMode ? true : ![200, 202].contains(response.statusCode), + requestFilter: (RequestOptions options) => !options.path.contains('api.twitch.tv'), + printRequestHeaders: true, + responseFilter: (response) => ![200, 202].contains(response.statusCode), ), ), ); diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index 8e0eea37..c421c259 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -52,8 +52,8 @@ class HomeViewController extends GetxController TwitchCredentials? twitchData; // StreamElements - SeCredentials? seCredentials; - SeMe? seMe; + Rx? seCredentials; + Rx? seMe; //chat input late TextEditingController chatInputController; @@ -92,16 +92,7 @@ class HomeViewController extends GetxController twitchData = Get.arguments[0]; - DataState seCreds = - await homeEvents.getSeCredentialsFromLocal(); - if (seCreds.error == null) { - seCredentials = seCreds.data; - DataState seMeResult = - await homeEvents.getSeMe(seCredentials!.accessToken); - if (seMeResult.error == null) { - seMe = seMeResult.data; - } - } + await setStreamElementsCredentials(); timerRefreshToken = Timer.periodic(const Duration(seconds: 13000), (Timer t) { @@ -111,9 +102,10 @@ class HomeViewController extends GetxController if (seCredentials != null) { homeEvents - .refreshSeAccessToken(seCredentials: seCredentials!) + .refreshSeAccessToken(seCredentials: seCredentials!.value) .then((value) => { - if (value.error == null) {seCredentials = value.data!} + if (value.error == null) + {seCredentials!.value = value.data!} }); } }); @@ -133,6 +125,23 @@ class HomeViewController extends GetxController super.onClose(); } + Future setStreamElementsCredentials() async { + DataState seCreds = + await homeEvents.getSeCredentialsFromLocal(); + if (seCreds.error == null) { + seCredentials = seCreds.data!.obs; + await setSeMe(seCredentials!.value); + } + } + + Future setSeMe(SeCredentials seCreds) async { + DataState seMeResult = + await homeEvents.getSeMe(seCredentials!.value.accessToken); + if (seMeResult.error == null) { + seMe = seMeResult.data!.obs; + } + } + void lazyPutChat(ChatGroup chatGroup) { Get.lazyPut( () => ChatViewController( diff --git a/lib/src/presentation/controllers/settings_view_controller.dart b/lib/src/presentation/controllers/settings_view_controller.dart index 13ec09b5..25f39349 100644 --- a/lib/src/presentation/controllers/settings_view_controller.dart +++ b/lib/src/presentation/controllers/settings_view_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:irllink/routes/app_routes.dart'; import 'package:irllink/src/core/params/streamelements_auth_params.dart'; +import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/presentation/controllers/home_view_controller.dart'; import 'package:irllink/src/presentation/controllers/store_controller.dart'; import 'package:irllink/src/presentation/controllers/tts_controller.dart'; @@ -93,18 +94,49 @@ class SettingsViewController extends GetxController { Future loginStreamElements() async { StreamelementsAuthParams params = const StreamelementsAuthParams(); await streamelementsEvents.login(params: params).then((value) { - if (value.error == null) { - //TODO: snackbar error + if (value.error != null) { + Get.snackbar( + "Error", + "Login failed: ${value.error}", + snackPosition: SnackPosition.BOTTOM, + icon: const Icon(Icons.error_outline, color: Colors.red), + borderWidth: 1, + borderColor: Colors.red, + ); } else { - //TODO: snackbar success - Get.back(); + homeViewController.setStreamElementsCredentials(); + homeViewController.seCredentials?.refresh(); + homeViewController.seMe?.refresh(); + Get.snackbar( + "StreamElements", + "Login successfull", + snackPosition: SnackPosition.BOTTOM, + icon: const Icon(Icons.check, color: Colors.green), + borderWidth: 1, + borderColor: Colors.green, + ); } }); } Future disconnectStreamElements() async { - if( homeViewController.seCredentials == null) return; - await streamelementsEvents.disconnect(homeViewController.seCredentials!.accessToken); + if (homeViewController.seCredentials == null) return; + DataState result = await streamelementsEvents + .disconnect(homeViewController.seCredentials!.value.accessToken); + if (result.error == null) { + homeViewController.seCredentials = null; + homeViewController.seMe = null; + homeViewController.seCredentials?.refresh(); + homeViewController.seMe?.refresh(); + Get.snackbar( + "StreamElements", + "Successfully disconnected.", + snackPosition: SnackPosition.BOTTOM, + icon: const Icon(Icons.check, color: Colors.green), + borderWidth: 1, + borderColor: Colors.green, + ); + } } void removeHiddenUser(userId) { diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 1a6030e7..fe30afd6 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -66,13 +66,13 @@ class StreamelementsViewController extends GetxController Future applySettings() async { globals.talker?.info('Applying StreamElements settings'); if (homeViewController.seCredentials == null) return; - if (jwt != homeViewController.seCredentials!.accessToken) { - jwt = homeViewController.seCredentials!.accessToken; + if (jwt != homeViewController.seCredentials!.value.accessToken) { + jwt = homeViewController.seCredentials!.value.accessToken; socket?.dispose(); socket = null; activities.clear(); if (homeViewController.seMe != null) { - handleGetMe(homeViewController.seMe!); + handleGetMe(homeViewController.seMe!.value); } connectWebsocket(); } diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index a61e8487..e8bb8ab9 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -15,7 +15,6 @@ class StreamElements extends GetView { @override Widget build(BuildContext context) { - bool isLoggedIn = controller.homeViewController.seCredentials != null; return Column( children: [ Row( @@ -65,11 +64,14 @@ class StreamElements extends GetView { ), child: Column( children: [ - isLoggedIn + controller.homeViewController.seCredentials != null && + controller.homeViewController.seMe != null ? Column( children: [ - profile( - controller.homeViewController.seMe!, + Obx( + () => _profile( + controller.homeViewController.seMe!.value, + ), ), Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -123,7 +125,7 @@ class StreamElements extends GetView { ); } - Widget profile(SeMe me) { + Widget _profile(SeMe me) { return Row( children: [ Image( From d13ad3bc605d505d451066db09d468a3ffc5eaf3 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 11:59:15 +0900 Subject: [PATCH 13/30] fix refresh view SE --- .../controllers/home_view_controller.dart | 27 ++--- .../controllers/settings_view_controller.dart | 16 +-- .../streamelements_view_controller.dart | 10 +- .../widgets/settings/stream_elements.dart | 106 +++++++++--------- 4 files changed, 80 insertions(+), 79 deletions(-) diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index c421c259..f59409ab 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -43,7 +43,7 @@ class HomeViewController extends GetxController SplitViewController? splitViewController = SplitViewController(limits: [null, WeightLimit(min: 0.12, max: 0.92)]); - //TABS + // Tabs late TabController tabController; Rx tabIndex = 0.obs; RxList tabElements = [].obs; @@ -52,17 +52,17 @@ class HomeViewController extends GetxController TwitchCredentials? twitchData; // StreamElements - Rx? seCredentials; - Rx? seMe; + Rx seCredentials = null.obs; + Rx seMe = null.obs; + StreamelementsViewController? streamelementsViewController; - //chat input + // Chat input late TextEditingController chatInputController; RxList twitchEmotes = [].obs; - //emote picker + // Emote picker RxBool isPickingEmote = false.obs; ObsTabViewController? obsTabViewController; - StreamelementsViewController? streamelementsViewController; late Rx settings = const Settings.defaultSettings().obs; @@ -72,6 +72,7 @@ class HomeViewController extends GetxController RxBool displayDashboard = false.obs; + // Chats RxList channels = [].obs; ChatGroup? selectedChatGroup; int? selectedChatIndex; @@ -100,12 +101,12 @@ class HomeViewController extends GetxController if (value.error == null) {twitchData = value.data!} }); - if (seCredentials != null) { + if (seCredentials.value != null) { homeEvents - .refreshSeAccessToken(seCredentials: seCredentials!.value) + .refreshSeAccessToken(seCredentials: seCredentials.value!) .then((value) => { if (value.error == null) - {seCredentials!.value = value.data!} + {seCredentials.value = value.data!} }); } }); @@ -129,14 +130,14 @@ class HomeViewController extends GetxController DataState seCreds = await homeEvents.getSeCredentialsFromLocal(); if (seCreds.error == null) { - seCredentials = seCreds.data!.obs; - await setSeMe(seCredentials!.value); + seCredentials.value = seCreds.data!; + await setSeMe(seCredentials.value!); } } Future setSeMe(SeCredentials seCreds) async { DataState seMeResult = - await homeEvents.getSeMe(seCredentials!.value.accessToken); + await homeEvents.getSeMe(seCredentials.value!.accessToken); if (seMeResult.error == null) { seMe = seMeResult.data!.obs; } @@ -170,7 +171,7 @@ class HomeViewController extends GetxController bool isSubscribed = Get.find().isSubscribed(); if ((twitchData == null && isSubscribed) || - isSubscribed && seCredentials != null) { + isSubscribed && seCredentials.value != null) { streamelementsViewController = Get.find(); StreamelementsTabView streamelementsPage = const StreamelementsTabView(); tabElements.add(streamelementsPage); diff --git a/lib/src/presentation/controllers/settings_view_controller.dart b/lib/src/presentation/controllers/settings_view_controller.dart index 25f39349..50390bf0 100644 --- a/lib/src/presentation/controllers/settings_view_controller.dart +++ b/lib/src/presentation/controllers/settings_view_controller.dart @@ -105,8 +105,8 @@ class SettingsViewController extends GetxController { ); } else { homeViewController.setStreamElementsCredentials(); - homeViewController.seCredentials?.refresh(); - homeViewController.seMe?.refresh(); + homeViewController.seCredentials.refresh(); + homeViewController.seMe.refresh(); Get.snackbar( "StreamElements", "Login successfull", @@ -120,14 +120,14 @@ class SettingsViewController extends GetxController { } Future disconnectStreamElements() async { - if (homeViewController.seCredentials == null) return; + if (homeViewController.seCredentials.value == null) return; DataState result = await streamelementsEvents - .disconnect(homeViewController.seCredentials!.value.accessToken); + .disconnect(homeViewController.seCredentials.value!.accessToken); if (result.error == null) { - homeViewController.seCredentials = null; - homeViewController.seMe = null; - homeViewController.seCredentials?.refresh(); - homeViewController.seMe?.refresh(); + homeViewController.seCredentials.value = null; + homeViewController.seMe.value = null; + homeViewController.seCredentials.refresh(); + homeViewController.seMe.refresh(); Get.snackbar( "StreamElements", "Successfully disconnected.", diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index fe30afd6..dfa70a31 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -65,14 +65,14 @@ class StreamelementsViewController extends GetxController Future applySettings() async { globals.talker?.info('Applying StreamElements settings'); - if (homeViewController.seCredentials == null) return; - if (jwt != homeViewController.seCredentials!.value.accessToken) { - jwt = homeViewController.seCredentials!.value.accessToken; + if (homeViewController.seCredentials.value == null) return; + if (jwt != homeViewController.seCredentials.value!.accessToken) { + jwt = homeViewController.seCredentials.value!.accessToken; socket?.dispose(); socket = null; activities.clear(); - if (homeViewController.seMe != null) { - handleGetMe(homeViewController.seMe!.value); + if (homeViewController.seMe.value != null) { + handleGetMe(homeViewController.seMe.value!); } connectWebsocket(); } diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index e8bb8ab9..07ffa96b 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -62,63 +62,63 @@ class StreamElements extends GetView { ), color: Theme.of(context).colorScheme.secondary, ), - child: Column( - children: [ - controller.homeViewController.seCredentials != null && - controller.homeViewController.seMe != null - ? Column( - children: [ - Obx( - () => _profile( - controller.homeViewController.seMe!.value, + child: Obx( + () => Column( + children: [ + controller.homeViewController.seCredentials.value != null && + controller.homeViewController.seMe.value != null + ? Column( + children: [ + _profile( + controller.homeViewController.seMe.value!, ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Image( - image: AssetImage( - "lib/assets/streamelements/seLogo.png"), - width: 30, - ), - const SizedBox( - width: 12, - ), - InkWell( - onTap: (() => - {controller.disconnectStreamElements()}), - child: const Text( - 'Logout', - style: TextStyle(fontSize: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Image( + image: AssetImage( + "lib/assets/streamelements/seLogo.png"), + width: 30, + ), + const SizedBox( + width: 12, ), + InkWell( + onTap: (() => + {controller.disconnectStreamElements()}), + child: const Text( + 'Logout', + style: TextStyle(fontSize: 16), + ), + ), + ], + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Image( + image: AssetImage( + "lib/assets/streamelements/seLogo.png"), + width: 30, + ), + const SizedBox( + width: 12, + ), + InkWell( + onTap: (() => {controller.loginStreamElements()}), + child: const Text( + 'Login with StreamElements', + style: TextStyle(fontSize: 16), ), - ], - ), - ], - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Image( - image: AssetImage( - "lib/assets/streamelements/seLogo.png"), - width: 30, - ), - const SizedBox( - width: 12, - ), - InkWell( - onTap: (() => {controller.loginStreamElements()}), - child: const Text( - 'Login with StreamElements', - style: TextStyle(fontSize: 16), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ], From f45e3ff87e157e6c6b99a9ab8f64b1130919cb06 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 11:59:42 +0900 Subject: [PATCH 14/30] fix --- lib/src/presentation/controllers/home_view_controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index f59409ab..7c7170d0 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -139,7 +139,7 @@ class HomeViewController extends GetxController DataState seMeResult = await homeEvents.getSeMe(seCredentials.value!.accessToken); if (seMeResult.error == null) { - seMe = seMeResult.data!.obs; + seMe.value = seMeResult.data!; } } From 4a1f5470628457ecacd41e3120acc8b19d26df64 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 12:46:44 +0900 Subject: [PATCH 15/30] fix Rx to Rxn --- lib/src/presentation/controllers/home_view_controller.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index 7c7170d0..4d3b3809 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -52,8 +52,8 @@ class HomeViewController extends GetxController TwitchCredentials? twitchData; // StreamElements - Rx seCredentials = null.obs; - Rx seMe = null.obs; + Rxn seCredentials = Rxn(); + Rxn seMe = Rxn(); StreamelementsViewController? streamelementsViewController; // Chat input From da51c44188c92fd90a71ef2b3c6e96a4d6cb7e17 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 12:47:21 +0900 Subject: [PATCH 16/30] Remove uncessecary ? --- lib/src/presentation/controllers/home_view_controller.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/presentation/controllers/home_view_controller.dart b/lib/src/presentation/controllers/home_view_controller.dart index 4d3b3809..2443c6a5 100644 --- a/lib/src/presentation/controllers/home_view_controller.dart +++ b/lib/src/presentation/controllers/home_view_controller.dart @@ -52,8 +52,8 @@ class HomeViewController extends GetxController TwitchCredentials? twitchData; // StreamElements - Rxn seCredentials = Rxn(); - Rxn seMe = Rxn(); + Rxn seCredentials = Rxn(); + Rxn seMe = Rxn(); StreamelementsViewController? streamelementsViewController; // Chat input From 37c90d761069e2176c339fa5b162d28917d3199d Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 14:05:32 +0900 Subject: [PATCH 17/30] style --- lib/src/core/utils/init_dio.dart | 2 +- .../streamelements_view_controller.dart | 1 - lib/src/presentation/views/settings_view.dart | 4 +- .../widgets/settings/stream_elements.dart | 93 +++++++++++-------- 4 files changed, 58 insertions(+), 42 deletions(-) diff --git a/lib/src/core/utils/init_dio.dart b/lib/src/core/utils/init_dio.dart index 71ee4649..5e8148af 100644 --- a/lib/src/core/utils/init_dio.dart +++ b/lib/src/core/utils/init_dio.dart @@ -12,7 +12,7 @@ Dio initDio() { settings: TalkerDioLoggerSettings( requestFilter: (RequestOptions options) => !options.path.contains('api.twitch.tv'), printRequestHeaders: true, - responseFilter: (response) => ![200, 202].contains(response.statusCode), + // responseFilter: (response) => ![200, 202].contains(response.statusCode), ), ), ); diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index dfa70a31..4f2df114 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -64,7 +64,6 @@ class StreamelementsViewController extends GetxController } Future applySettings() async { - globals.talker?.info('Applying StreamElements settings'); if (homeViewController.seCredentials.value == null) return; if (jwt != homeViewController.seCredentials.value!.accessToken) { jwt = homeViewController.seCredentials.value!.accessToken; diff --git a/lib/src/presentation/views/settings_view.dart b/lib/src/presentation/views/settings_view.dart index 3f968333..f997eddc 100644 --- a/lib/src/presentation/views/settings_view.dart +++ b/lib/src/presentation/views/settings_view.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; @@ -535,7 +537,7 @@ class SettingsView extends GetView { ), const SizedBox(height: 10), Visibility( - visible: kDebugMode || + visible: Platform.isIOS || kDebugMode || storeController.storeFound.value && storeController.products.isNotEmpty, child: StreamElements(controller: controller), diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index 07ffa96b..2c1ccd06 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -72,50 +72,64 @@ class StreamElements extends GetView { _profile( controller.homeViewController.seMe.value!, ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Image( - image: AssetImage( - "lib/assets/streamelements/seLogo.png"), - width: 30, - ), - const SizedBox( - width: 12, + const SizedBox( + height: 8, + ), + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(8), ), - InkWell( - onTap: (() => - {controller.disconnectStreamElements()}), - child: const Text( - 'Logout', - style: TextStyle(fontSize: 16), - ), + color: Theme.of(context) + .colorScheme + .tertiaryContainer, + ), + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: InkWell( + onTap: (() => + {controller.disconnectStreamElements()}), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image( + image: AssetImage( + "lib/assets/streamelements/seLogo.png"), + width: 30, + ), + SizedBox( + width: 12, + ), + Text( + 'Logout', + style: TextStyle(fontSize: 16), + ), + ], ), - ], + ), ), ], ) - : Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Image( - image: AssetImage( - "lib/assets/streamelements/seLogo.png"), - width: 30, - ), - const SizedBox( - width: 12, - ), - InkWell( - onTap: (() => {controller.loginStreamElements()}), - child: const Text( + : InkWell( + onTap: (() => {controller.loginStreamElements()}), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image( + image: AssetImage( + "lib/assets/streamelements/seLogo.png"), + width: 30, + ), + SizedBox( + width: 12, + ), + Text( 'Login with StreamElements', style: TextStyle(fontSize: 16), ), - ), - ], + ], + ), ), ], ), @@ -128,10 +142,11 @@ class StreamElements extends GetView { Widget _profile(SeMe me) { return Row( children: [ - Image( - image: NetworkImage(me.avatar), - width: 40, + CircleAvatar( + foregroundImage: NetworkImage(me.avatar), + radius: 20, ), + const SizedBox(width: 8,), Text(me.displayName), ], ); From 56cfc4f1c5ae0c0706e8cdc23b054d45b7482c53 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 14:28:52 +0900 Subject: [PATCH 18/30] Comment out not working stuff song request due to oauth2 --- .../stream_elements/se_song_requests.dart | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/lib/src/presentation/widgets/stream_elements/se_song_requests.dart b/lib/src/presentation/widgets/stream_elements/se_song_requests.dart index 46966de4..dc1cbf63 100644 --- a/lib/src/presentation/widgets/stream_elements/se_song_requests.dart +++ b/lib/src/presentation/widgets/stream_elements/se_song_requests.dart @@ -19,34 +19,34 @@ class SeSongRequests extends GetView { margin: const EdgeInsets.only(left: 20, right: 20, top: 20), child: Column( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Wrap( - children: [ - // const Icon(Icons.skip_previous), - InkWell( - onTap: () { - controller.updatePlayerState(controller.isPlaying.value ? 'pause' : 'play'); - }, - child: controller.isPlaying.value ? const Icon(Icons.pause) : const Icon(Icons.play_arrow_outlined), - ), - InkWell( - onTap: () { - controller.nextSong(); - }, - child: const Icon(Icons.skip_next), - ), - ], - ), - InkWell( - onTap: () { - controller.resetQueue(); - }, - child: const Icon(Icons.delete), - ), - ], - ), + // Row( + // mainAxisAlignment: MainAxisAlignment.spaceBetween, + // children: [ + // Wrap( + // children: [ + // // const Icon(Icons.skip_previous), + // InkWell( + // onTap: () { + // controller.updatePlayerState(controller.isPlaying.value ? 'pause' : 'play'); + // }, + // child: controller.isPlaying.value ? const Icon(Icons.pause) : const Icon(Icons.play_arrow_outlined), + // ), + // InkWell( + // onTap: () { + // controller.nextSong(); + // }, + // child: const Icon(Icons.skip_next), + // ), + // ], + // ), + // InkWell( + // onTap: () { + // controller.resetQueue(); + // }, + // child: const Icon(Icons.delete), + // ), + // ], + // ), const Padding( padding: EdgeInsets.only(top: 10), ), @@ -168,18 +168,18 @@ class SeSongRequests extends GetView { ], ), ), - Visibility( - visible: removable, - child: InkWell( - onTap: () { - controller.removeSong(song); - }, - child: const Icon( - Icons.close, - color: Colors.red, - ), - ), - ), + // Visibility( + // visible: removable, + // child: InkWell( + // onTap: () { + // controller.removeSong(song); + // }, + // child: const Icon( + // Icons.close, + // color: Colors.red, + // ), + // ), + // ), ], ), ); From 6fd6a3c2c5a49d55036991407231fa3ac91e46ff Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 18:25:15 +0900 Subject: [PATCH 19/30] verify purchase firebase link --- .../streamelements_repository_impl.dart | 13 +++++++++---- .../controllers/store_controller.dart | 16 +++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index be07f1b0..e46ac0fe 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -42,7 +42,8 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { String accessToken = Uri.parse(result).queryParameters['access_token']!; String refreshToken = Uri.parse(result).queryParameters['refresh_token']!; - int expiresIn = int.parse(Uri.parse(result).queryParameters['expires_in']!); + int expiresIn = + int.parse(Uri.parse(result).queryParameters['expires_in']!); globals.talker?.info('StreamElements login successful'); DataState tokenInfos = await validateToken(accessToken); @@ -188,9 +189,13 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { globals.talker?.info('Scopes are the same: OK'); //refresh the access token to be sure the token is going to be valid after starting the app - await refreshAccessToken(seCredentials) - .then((value) => seCredentials = value.data!); - + DataState creds = await refreshAccessToken(seCredentials); + if (creds.error == null) { + seCredentials = creds.data!; + } else { + return DataFailed("Error refreshing SE Token"); + } + globals.talker?.info('SE token refreshed.'); return DataSuccess(seCredentials); diff --git a/lib/src/presentation/controllers/store_controller.dart b/lib/src/presentation/controllers/store_controller.dart index fb4da4a8..1d085789 100644 --- a/lib/src/presentation/controllers/store_controller.dart +++ b/lib/src/presentation/controllers/store_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -29,10 +30,12 @@ class StoreController extends GetxController { //Function isSubscribed bool isSubscribed() { - return Platform.isIOS || kDebugMode || purchases.firstWhereOrNull( - (element) => element.productID == kIds.first, - ) != - null; + return Platform.isIOS || + kDebugMode || + purchases.firstWhereOrNull( + (element) => element.productID == kIds.first, + ) != + null; } // Function get subscription price @@ -122,10 +125,13 @@ class StoreController extends GetxController { String? pruchaseToken = purchaseDetails.verificationData.serverVerificationData; HomeViewController homeViewController = Get.find(); + final remoteConfig = FirebaseRemoteConfig.instance; + await remoteConfig.fetchAndActivate(); + String url = remoteConfig.getString('verify_android_purchase'); var dio = initDio(); try { await dio.post( - 'https://www.irllink.com/api/verify-android-purchase', + url, data: { 'purchaseToken': pruchaseToken, 'twitchId': homeViewController.twitchData?.twitchUser.id, From b76220c8bfe932da63c8569f61505c8dbdd61f0f Mon Sep 17 00:00:00 2001 From: LezdCS Date: Tue, 21 May 2024 18:57:39 +0900 Subject: [PATCH 20/30] Prepare fields and models for SE overlay and jwt --- .../stream_elements_settings_dto.dart | 8 + lib/src/domain/entities/settings.dart | 2 + .../settings/stream_elements_settings.dart | 12 ++ .../controllers/settings_view_controller.dart | 5 + .../widgets/settings/stream_elements.dart | 171 +++++++++++++++++- 5 files changed, 197 insertions(+), 1 deletion(-) diff --git a/lib/src/data/entities/settings/stream_elements_settings_dto.dart b/lib/src/data/entities/settings/stream_elements_settings_dto.dart index 082e45a2..237f680e 100644 --- a/lib/src/data/entities/settings/stream_elements_settings_dto.dart +++ b/lib/src/data/entities/settings/stream_elements_settings_dto.dart @@ -10,6 +10,8 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { required super.showRaidActivity, required super.showHostActivity, required super.showMerchActivity, + required super.jwt, + required super.overlayToken, }); @override @@ -21,6 +23,8 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { 'showRaidActivity': showRaidActivity, 'showHostActivity': showHostActivity, 'showMerchActivity': showMerchActivity, + 'jwt': jwt, + 'overlayToken': overlayToken, }; factory StreamElementsSettingsDTO.fromJson(Map map) { @@ -53,6 +57,10 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { const Settings.defaultSettings() .streamElementsSettings! .showMerchActivity, + jwt: map['jwt'] ?? + const Settings.defaultSettings().streamElementsSettings!.jwt, + overlayToken: map['overlayToken'] ?? + const Settings.defaultSettings().streamElementsSettings!.overlayToken, ); } } diff --git a/lib/src/domain/entities/settings.dart b/lib/src/domain/entities/settings.dart index de5aad68..e1aefa6d 100644 --- a/lib/src/domain/entities/settings.dart +++ b/lib/src/domain/entities/settings.dart @@ -101,6 +101,8 @@ class Settings extends Equatable { showRaidActivity: true, showHostActivity: true, showMerchActivity: true, + jwt: null, + overlayToken: null, ), //TTS SETTINGS diff --git a/lib/src/domain/entities/settings/stream_elements_settings.dart b/lib/src/domain/entities/settings/stream_elements_settings.dart index 011af4b1..cc657b79 100644 --- a/lib/src/domain/entities/settings/stream_elements_settings.dart +++ b/lib/src/domain/entities/settings/stream_elements_settings.dart @@ -8,6 +8,8 @@ class StreamElementsSettings extends Equatable { final bool showRaidActivity; final bool showHostActivity; final bool showMerchActivity; + final String? jwt; + final String? overlayToken; const StreamElementsSettings({ required this.showFollowerActivity, @@ -17,6 +19,8 @@ class StreamElementsSettings extends Equatable { required this.showRaidActivity, required this.showHostActivity, required this.showMerchActivity, + required this.jwt, + required this.overlayToken, }); @override @@ -29,6 +33,8 @@ class StreamElementsSettings extends Equatable { showRaidActivity, showHostActivity, showMerchActivity, + jwt, + overlayToken, ]; } @@ -40,6 +46,8 @@ class StreamElementsSettings extends Equatable { 'showRaidActivity': showRaidActivity, 'showHostActivity': showHostActivity, 'showMerchActivity': showMerchActivity, + 'jwt': jwt, + 'overlayToken': overlayToken, }; @override @@ -53,6 +61,8 @@ class StreamElementsSettings extends Equatable { bool? showRaidActivity, bool? showHostActivity, bool? showMerchActivity, + String? jwt, + String? overlayToken, }) { return StreamElementsSettings( showFollowerActivity: showFollowerActivity ?? this.showFollowerActivity, @@ -63,6 +73,8 @@ class StreamElementsSettings extends Equatable { showRaidActivity: showRaidActivity ?? this.showRaidActivity, showHostActivity: showHostActivity ?? this.showHostActivity, showMerchActivity: showMerchActivity ?? this.showMerchActivity, + jwt: jwt ?? this.jwt, + overlayToken: overlayToken ?? this.overlayToken, ); } } diff --git a/lib/src/presentation/controllers/settings_view_controller.dart b/lib/src/presentation/controllers/settings_view_controller.dart index 50390bf0..8d2b2fe1 100644 --- a/lib/src/presentation/controllers/settings_view_controller.dart +++ b/lib/src/presentation/controllers/settings_view_controller.dart @@ -36,6 +36,9 @@ class SettingsViewController extends GetxController { RxBool obsWebsocketPasswordShow = false.obs; RxBool obsWebsocketUrlShow = false.obs; RxBool seJwtShow = false.obs; + RxBool seOverlayTokenShow = false.obs; + late TextEditingController seJwtInputController; + late TextEditingController seOverlayTokenInputController; late TextEditingController addTtsIgnoredUsersController; late TextEditingController addTtsIgnoredPrefixsController; @@ -60,6 +63,8 @@ class SettingsViewController extends GetxController { addTtsIgnoredUsersController = TextEditingController(); addTtsIgnoredPrefixsController = TextEditingController(); addTtsAllowedPrefixsController = TextEditingController(); + seJwtInputController = TextEditingController(); + seOverlayTokenInputController = TextEditingController(); usernamesHiddenUsers = [].obs; super.onInit(); diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index 2c1ccd06..ffb832af 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; +import 'package:url_launcher/url_launcher_string.dart'; import '../../controllers/settings_view_controller.dart'; class StreamElements extends GetView { @@ -75,6 +76,134 @@ class StreamElements extends GetView { const SizedBox( height: 8, ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 7, + child: TextFormField( + controller: controller.seJwtInputController, + obscureText: !controller.seJwtShow.value, + onChanged: (value) { + //TODO: edit JWT in SE local settings + + // controller.homeViewController.settings.value = + // controller + // .homeViewController.settings.value + // .copyWith(obsWebsocketUrl: value); + controller.saveSettings(); + }, + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyLarge! + .color, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 7), + enabledBorder: Theme.of(context) + .inputDecorationTheme + .border, + hintText: 'JWT', + labelText: 'JWT', + labelStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .tertiary, + ), + suffixIcon: IconButton( + icon: Icon( + controller.obsWebsocketUrlShow.value + ? Icons.visibility + : Icons.visibility_off), + color: Theme.of(context) + .primaryIconTheme + .color, + onPressed: () { + controller.obsWebsocketUrlShow.value = + !controller + .obsWebsocketUrlShow.value; + }, + ), + ), + ), + ), + ], + ), + _jwtExplanation(context), + const SizedBox( + height: 8, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 7, + child: TextFormField( + controller: + controller.seOverlayTokenInputController, + obscureText: + !controller.seOverlayTokenShow.value, + onChanged: (value) { + //TODO: edit overlayToken in SE local settings + + // controller.homeViewController.settings.value = + // controller + // .homeViewController.settings.value + // .copyWith(obsWebsocketUrl: value); + controller.saveSettings(); + }, + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyLarge! + .color, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 7), + enabledBorder: Theme.of(context) + .inputDecorationTheme + .border, + hintText: 'Token', + labelText: 'Overlay Token', + labelStyle: TextStyle( + color: Theme.of(context) + .colorScheme + .tertiary, + ), + suffixIcon: IconButton( + icon: Icon( + controller.obsWebsocketUrlShow.value + ? Icons.visibility + : Icons.visibility_off), + color: Theme.of(context) + .primaryIconTheme + .color, + onPressed: () { + controller.obsWebsocketUrlShow.value = + !controller + .obsWebsocketUrlShow.value; + }, + ), + ), + ), + ), + ], + ), + Text( + 'Same as above for the overlay token', + style: TextStyle( + color: + Theme.of(context).textTheme.bodyLarge!.color, + ), + ), + const SizedBox( + height: 8, + ), Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all( @@ -146,9 +275,49 @@ class StreamElements extends GetView { foregroundImage: NetworkImage(me.avatar), radius: 20, ), - const SizedBox(width: 8,), + const SizedBox( + width: 8, + ), Text(me.displayName), ], ); } + + Widget _jwtExplanation(BuildContext context) { + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: 'To get your Access Token ', + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + ), + ), + WidgetSpan( + child: InkWell( + onTap: () { + launchUrlString( + "https://streamelements.com/dashboard/account/channels", + mode: LaunchMode.externalApplication, + ); + }, + child: Text( + "click here", + style: TextStyle( + color: Theme.of(context).colorScheme.tertiary, + ), + ), + ), + ), + TextSpan( + text: '. Then press "Show Secret" and copy your JWT Token!', + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + ), + ), + ], + ), + ); + } } From 30aae8f4d54d173a8595a3b7615ccc97c13150b3 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 11:33:02 +0900 Subject: [PATCH 21/30] use JWT and overlay token in SE view controller --- .../streamelements_view_controller.dart | 87 +++++++++++-------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 4f2df114..00a930e1 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:irllink/src/core/resources/data_state.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_activity.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_me.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; @@ -34,7 +35,8 @@ class StreamelementsViewController extends GetxController RxList overlays = [].obs; Socket? socket; - late String jwt = ""; + late String? jwt; + late String? overlayToken; RxBool isSocketConnected = false.obs; @@ -60,66 +62,78 @@ class StreamelementsViewController extends GetxController } void replayEvent(SeActivity activity) { - streamelementsEvents.replayActivity(jwt, activity); + String accessToken = homeViewController.seCredentials.value!.accessToken; + streamelementsEvents.replayActivity(accessToken, activity); } Future applySettings() async { if (homeViewController.seCredentials.value == null) return; - if (jwt != homeViewController.seCredentials.value!.accessToken) { - jwt = homeViewController.seCredentials.value!.accessToken; - socket?.dispose(); - socket = null; - activities.clear(); - if (homeViewController.seMe.value != null) { - handleGetMe(homeViewController.seMe.value!); - } - connectWebsocket(); + jwt = homeViewController.settings.value.streamElementsSettings?.jwt; + overlayToken = + homeViewController.settings.value.streamElementsSettings?.overlayToken; + socket?.dispose(); + socket = null; + activities.clear(); + if (homeViewController.seMe.value != null) { + handleGetMe(homeViewController.seMe.value!); } + connectWebsocket(); } - void handleGetMe(SeMe me) { + Future handleGetMe(SeMe me) async { userSeProfile = me; + String? accessToken = homeViewController.seCredentials.value?.accessToken; + if (accessToken == null) { + globals.talker?.error('There is no accessToken to use for SE api calls.'); + return; + } + streamelementsEvents - .getOverlays(jwt, me.id) - .then((value) => overlays.value = value.data!); + .getOverlays(accessToken, me.id) + .then((value) => overlays.value = value.data ?? []); streamelementsEvents - .getLastActivities(jwt, me.id) - .then((value) => activities.value = value.data!); - streamelementsEvents.getSongQueue(jwt, me.id).then((value) => { - if (value.error == null) {songRequestQueue.value = value.data!} - }); + .getLastActivities(accessToken, me.id) + .then((value) => activities.value = value.data ?? []); streamelementsEvents - .getSongPlaying(jwt, me.id) - .then((value) => currentSong.value = value.data!); + .getSongPlaying(accessToken, me.id) + .then((value) => currentSong.value = value.data); + + if (jwt != null) { + DataState> songQueue = + await streamelementsEvents.getSongQueue(jwt!, me.id); + if (songQueue.error == null) { + songRequestQueue.value = songQueue.data ?? []; + } + } } void updatePlayerState(String state) { - if (userSeProfile == null) return; - streamelementsEvents.updatePlayerState(jwt, userSeProfile!.id, state); + if (userSeProfile == null || jwt == null) return; + streamelementsEvents.updatePlayerState(jwt!, userSeProfile!.id, state); } void nextSong() { - if (userSeProfile == null) return; - streamelementsEvents.nextSong(jwt, userSeProfile!.id); + if (userSeProfile == null || jwt == null) return; + streamelementsEvents.nextSong(jwt!, userSeProfile!.id); } void removeSong(SeSong song) { - if (userSeProfile == null) return; - streamelementsEvents.removeSong(jwt, userSeProfile!.id, song.id); + if (userSeProfile == null || jwt == null) return; + streamelementsEvents.removeSong(jwt!, userSeProfile!.id, song.id); } void resetQueue() { - if (userSeProfile == null) return; - streamelementsEvents.resetQueue(jwt, userSeProfile!.id); + if (userSeProfile == null || jwt == null) return; + streamelementsEvents.resetQueue(jwt!, userSeProfile!.id); } /// Connect to WebSocket Future connectWebsocket() async { - socket = io('https://realtime.streamelements.com', + socket = io( + 'https://realtime.streamelements.com', OptionBuilder().setTransports(['websocket']) - // .disableAutoConnect() - .build() - ); + // .disableAutoConnect() + .build()); // socket!.connect(); socket!.on('connect_error', (data) => onError()); @@ -188,7 +202,12 @@ class StreamelementsViewController extends GetxController } Future onConnect() async { - socket?.emit('authenticate', {"method": 'oauth2', "token": jwt}); + String? accessToken = homeViewController.seCredentials.value?.accessToken; + if (accessToken != null) { + socket?.emit('authenticate', {"method": 'oauth2', "token": accessToken}); + } else { + globals.talker?.error('There is no accessToken to use for SE weboscket.'); + } } Future onError() async { From 21db9cfb9dd4501fd0cb418004e96c707b3237da Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 12:10:52 +0900 Subject: [PATCH 22/30] StreamElements overlays tab --- .../streamelements_view_controller.dart | 2 +- .../widgets/stream_elements/se_overlays.dart | 69 +++++++++++++++++++ .../widgets/tabs/streamelements_tab_view.dart | 5 ++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 lib/src/presentation/widgets/stream_elements/se_overlays.dart diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 00a930e1..319dfb5e 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -46,7 +46,7 @@ class StreamelementsViewController extends GetxController Future onInit() async { homeViewController = Get.find(); - tabController = TabController(length: 2, vsync: this); + tabController = TabController(length: 3, vsync: this); activitiesScrollController = ScrollController(); songRequestScrollController = ScrollController(); diff --git a/lib/src/presentation/widgets/stream_elements/se_overlays.dart b/lib/src/presentation/widgets/stream_elements/se_overlays.dart new file mode 100644 index 00000000..3509f460 --- /dev/null +++ b/lib/src/presentation/widgets/stream_elements/se_overlays.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:irllink/src/domain/entities/stream_elements/se_overlay.dart'; +import 'package:irllink/src/presentation/controllers/streamelements_view_controller.dart'; +import 'package:irllink/src/presentation/widgets/web_page_view.dart'; + +class SeOverlays extends GetView { + @override + final StreamelementsViewController controller; + + const SeOverlays({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + child: Obx( + () => ListView.separated( + shrinkWrap: true, + itemCount: controller.overlays.length, + separatorBuilder: (context, index) => const SizedBox( + height: 8, + ), + itemBuilder: (BuildContext context, int index) { + SeOverlay overlay = controller.overlays[index]; + return _overlayCollapsed(controller, overlay, context); + }, + ), + ), + ); + } +} + +Widget _overlayCollapsed(StreamelementsViewController controller, + SeOverlay overlay, BuildContext context) { + String overlayUrl = + 'https://streamelements.com/overlay/${overlay.id}/${controller.overlayToken}'; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + ), + padding: const EdgeInsets.only(top: 6, left: 10, right: 10, bottom: 6), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(overlay.name), + InkWell( + onTap: (() => {}), + // child: Icon(Icons.volume_off), + child: Icon(Icons.volume_up), + ), + ], + ), + Visibility( + visible: false, + child: WebPageView(overlay.name, overlayUrl), + ), + ], + ), + ); +} \ No newline at end of file diff --git a/lib/src/presentation/widgets/tabs/streamelements_tab_view.dart b/lib/src/presentation/widgets/tabs/streamelements_tab_view.dart index 979632c6..88c92cac 100644 --- a/lib/src/presentation/widgets/tabs/streamelements_tab_view.dart +++ b/lib/src/presentation/widgets/tabs/streamelements_tab_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:irllink/src/presentation/controllers/streamelements_view_controller.dart'; +import 'package:irllink/src/presentation/widgets/stream_elements/se_overlays.dart'; import 'package:irllink/src/presentation/widgets/stream_elements/se_song_requests.dart'; import '../stream_elements/se_activities_list.dart'; @@ -30,6 +31,7 @@ class StreamelementsTabView extends GetView { tabs: const [ Text("Activities"), Text("Song Requests"), + Text("Overlays"), ], ), ), @@ -47,6 +49,9 @@ class StreamelementsTabView extends GetView { SeSongRequests( controller: controller, ), + SeOverlays( + controller: controller, + ), ], ), ), From 43d448ed9396091f023305a06b2261504bcebe24 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 12:17:58 +0900 Subject: [PATCH 23/30] preview icon overlay --- .../widgets/stream_elements/se_overlays.dart | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/src/presentation/widgets/stream_elements/se_overlays.dart b/lib/src/presentation/widgets/stream_elements/se_overlays.dart index 3509f460..ae61890d 100644 --- a/lib/src/presentation/widgets/stream_elements/se_overlays.dart +++ b/lib/src/presentation/widgets/stream_elements/se_overlays.dart @@ -26,7 +26,7 @@ class SeOverlays extends GetView { ), itemBuilder: (BuildContext context, int index) { SeOverlay overlay = controller.overlays[index]; - return _overlayCollapsed(controller, overlay, context); + return _overlayRow(controller, overlay, context); }, ), ), @@ -34,7 +34,7 @@ class SeOverlays extends GetView { } } -Widget _overlayCollapsed(StreamelementsViewController controller, +Widget _overlayRow(StreamelementsViewController controller, SeOverlay overlay, BuildContext context) { String overlayUrl = 'https://streamelements.com/overlay/${overlay.id}/${controller.overlayToken}'; @@ -51,10 +51,18 @@ Widget _overlayCollapsed(StreamelementsViewController controller, Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(overlay.name), + Expanded( + child: Text(overlay.name), + ), + InkWell( + onTap: (() => {}), + child: Icon(Icons.preview), + ), + const SizedBox( + width: 10, + ), InkWell( onTap: (() => {}), - // child: Icon(Icons.volume_off), child: Icon(Icons.volume_up), ), ], @@ -66,4 +74,4 @@ Widget _overlayCollapsed(StreamelementsViewController controller, ], ), ); -} \ No newline at end of file +} From 718edbf68dde59d4430688cd29ed61c897254400 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 12:23:57 +0900 Subject: [PATCH 24/30] fix SE view controller --- .../controllers/streamelements_view_controller.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 319dfb5e..b39e4151 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -71,13 +71,12 @@ class StreamelementsViewController extends GetxController jwt = homeViewController.settings.value.streamElementsSettings?.jwt; overlayToken = homeViewController.settings.value.streamElementsSettings?.overlayToken; - socket?.dispose(); - socket = null; - activities.clear(); if (homeViewController.seMe.value != null) { handleGetMe(homeViewController.seMe.value!); } - connectWebsocket(); + if(!isSocketConnected.value) { + connectWebsocket(); + } } Future handleGetMe(SeMe me) async { From 8e3dc2ea1e77edd0843d0bcd69eac70ac15c34e8 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 12:54:37 +0900 Subject: [PATCH 25/30] put back Se song request buttons if there is JWT --- .../streamelements_view_controller.dart | 8 +- .../stream_elements/se_song_requests.dart | 84 ++++++++++--------- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index b39e4151..2e98f812 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -74,7 +74,7 @@ class StreamelementsViewController extends GetxController if (homeViewController.seMe.value != null) { handleGetMe(homeViewController.seMe.value!); } - if(!isSocketConnected.value) { + if (!isSocketConnected.value) { connectWebsocket(); } } @@ -162,6 +162,8 @@ class StreamelementsViewController extends GetxController }, ); + socket!.onAny((event, data) => globals.talker?.debug(data),); + socket!.on( 'songrequest:song:next', (data) => onNextSong(data), @@ -221,7 +223,9 @@ class StreamelementsViewController extends GetxController Future onAuthenticated(data) async { isSocketConnected.value = true; - globals.talker?.info('Connected to StreamElements websocket'); + socket?.emit('subscribe', {"room": 'songrequest::611168252645244a6f16ab67'}); + globals.talker?.info('SE WebSocket authenticated.'); + // socket?.emit('songrequest::mediashare', {"event": 'clients:get', "target": ["control", "widget"], 'data': {'deviceId': "9bbf7619-820ba74f", 'isInControl': false, 'timestamp': 1716348618427}}); } void onAddSongQueue(data) { diff --git a/lib/src/presentation/widgets/stream_elements/se_song_requests.dart b/lib/src/presentation/widgets/stream_elements/se_song_requests.dart index dc1cbf63..d4b84d8c 100644 --- a/lib/src/presentation/widgets/stream_elements/se_song_requests.dart +++ b/lib/src/presentation/widgets/stream_elements/se_song_requests.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:irllink/src/domain/entities/stream_elements/se_song.dart'; @@ -19,34 +20,37 @@ class SeSongRequests extends GetView { margin: const EdgeInsets.only(left: 20, right: 20, top: 20), child: Column( children: [ - // Row( - // mainAxisAlignment: MainAxisAlignment.spaceBetween, - // children: [ - // Wrap( - // children: [ - // // const Icon(Icons.skip_previous), - // InkWell( - // onTap: () { - // controller.updatePlayerState(controller.isPlaying.value ? 'pause' : 'play'); - // }, - // child: controller.isPlaying.value ? const Icon(Icons.pause) : const Icon(Icons.play_arrow_outlined), - // ), - // InkWell( - // onTap: () { - // controller.nextSong(); - // }, - // child: const Icon(Icons.skip_next), - // ), - // ], - // ), - // InkWell( - // onTap: () { - // controller.resetQueue(); - // }, - // child: const Icon(Icons.delete), - // ), - // ], - // ), + Visibility( + visible: controller.jwt != null, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Wrap( + children: [ + // const Icon(Icons.skip_previous), + InkWell( + onTap: () { + controller.updatePlayerState(controller.isPlaying.value ? 'pause' : 'play'); + }, + child: controller.isPlaying.value ? const Icon(Icons.pause) : const Icon(Icons.play_arrow_outlined), + ), + InkWell( + onTap: () { + controller.nextSong(); + }, + child: const Icon(Icons.skip_next), + ), + ], + ), + InkWell( + onTap: () { + controller.resetQueue(); + }, + child: const Icon(Icons.delete), + ), + ], + ), + ), const Padding( padding: EdgeInsets.only(top: 10), ), @@ -168,18 +172,18 @@ class SeSongRequests extends GetView { ], ), ), - // Visibility( - // visible: removable, - // child: InkWell( - // onTap: () { - // controller.removeSong(song); - // }, - // child: const Icon( - // Icons.close, - // color: Colors.red, - // ), - // ), - // ), + Visibility( + visible: controller.jwt != null && removable, + child: InkWell( + onTap: () { + controller.removeSong(song); + }, + child: const Icon( + Icons.close, + color: Colors.red, + ), + ), + ), ], ), ); From 78d48082a7865f3c3356522575f07f71949e50f1 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 12:56:04 +0900 Subject: [PATCH 26/30] . --- .../controllers/streamelements_view_controller.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 2e98f812..48f55698 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -223,9 +223,8 @@ class StreamelementsViewController extends GetxController Future onAuthenticated(data) async { isSocketConnected.value = true; - socket?.emit('subscribe', {"room": 'songrequest::611168252645244a6f16ab67'}); + // socket?.emit('subscribe', {"room": 'songrequest::611168252645244a6f16ab67'}); globals.talker?.info('SE WebSocket authenticated.'); - // socket?.emit('songrequest::mediashare', {"event": 'clients:get', "target": ["control", "widget"], 'data': {'deviceId': "9bbf7619-820ba74f", 'isInControl': false, 'timestamp': 1716348618427}}); } void onAddSongQueue(data) { From 3baf1159054ad07389a75a7aadd2ad6ce218023a Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 15:15:21 +0900 Subject: [PATCH 27/30] Preview overlay working --- .../streamelements_repository_impl.dart | 10 ++--- .../streamelements_view_controller.dart | 2 +- .../widgets/settings/stream_elements.dart | 36 ++++++++++------ .../widgets/stream_elements/se_overlays.dart | 41 +++++++++++++------ 4 files changed, 59 insertions(+), 30 deletions(-) diff --git a/lib/src/data/repositories/streamelements_repository_impl.dart b/lib/src/data/repositories/streamelements_repository_impl.dart index e46ac0fe..32000aca 100644 --- a/lib/src/data/repositories/streamelements_repository_impl.dart +++ b/lib/src/data/repositories/streamelements_repository_impl.dart @@ -282,7 +282,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future nextSong(String token, String userId) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "oAuth $token"; + dio.options.headers["Authorization"] = "Bearer $token"; await dio.post( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/skip', ); @@ -295,7 +295,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future removeSong(String token, String userId, String songId) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "oAuth $token"; + dio.options.headers["Authorization"] = "Bearer $token"; await dio.delete( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/queue/$songId', ); @@ -308,7 +308,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { Future resetQueue(String token, String userId) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "oAuth $token"; + dio.options.headers["Authorization"] = "Bearer $token"; await dio.delete( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/queue/', ); @@ -323,7 +323,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { List songs = []; var dio = initDio(); try { - dio.options.headers["Authorization"] = "oAuth $token"; + dio.options.headers["Authorization"] = "Bearer $token"; Response response = await dio.get( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/queue', ); @@ -374,7 +374,7 @@ class StreamelementsRepositoryImpl extends StreamelementsRepository { String token, String userId, String state) async { var dio = initDio(); try { - dio.options.headers["Authorization"] = "oAuth $token"; + dio.options.headers["Authorization"] = "Bearer $token"; await dio.post( 'https://api.streamelements.com/kappa/v2/songrequest/$userId/player/$state', ); diff --git a/lib/src/presentation/controllers/streamelements_view_controller.dart b/lib/src/presentation/controllers/streamelements_view_controller.dart index 48f55698..22c1df75 100644 --- a/lib/src/presentation/controllers/streamelements_view_controller.dart +++ b/lib/src/presentation/controllers/streamelements_view_controller.dart @@ -240,7 +240,7 @@ class StreamelementsViewController extends GetxController void onNextSong(data) { dynamic songData = data[0]["nextSong"]; - if (songData == {}) return; + if (songData == null || songData == {}) return; SeSong song = SeSong.fromJson(songData); currentSong.value = song; if (song.id != '') { diff --git a/lib/src/presentation/widgets/settings/stream_elements.dart b/lib/src/presentation/widgets/settings/stream_elements.dart index ffb832af..8c19b730 100644 --- a/lib/src/presentation/widgets/settings/stream_elements.dart +++ b/lib/src/presentation/widgets/settings/stream_elements.dart @@ -85,12 +85,18 @@ class StreamElements extends GetView { controller: controller.seJwtInputController, obscureText: !controller.seJwtShow.value, onChanged: (value) { - //TODO: edit JWT in SE local settings - - // controller.homeViewController.settings.value = - // controller - // .homeViewController.settings.value - // .copyWith(obsWebsocketUrl: value); + controller + .homeViewController.settings.value = + controller + .homeViewController.settings.value + .copyWith( + streamElementsSettings: controller + .homeViewController + .settings + .value + .streamElementsSettings! + .copyWith(jwt: value), + ); controller.saveSettings(); }, style: TextStyle( @@ -147,12 +153,18 @@ class StreamElements extends GetView { obscureText: !controller.seOverlayTokenShow.value, onChanged: (value) { - //TODO: edit overlayToken in SE local settings - - // controller.homeViewController.settings.value = - // controller - // .homeViewController.settings.value - // .copyWith(obsWebsocketUrl: value); + controller + .homeViewController.settings.value = + controller + .homeViewController.settings.value + .copyWith( + streamElementsSettings: controller + .homeViewController + .settings + .value + .streamElementsSettings! + .copyWith(overlayToken: value), + ); controller.saveSettings(); }, style: TextStyle( diff --git a/lib/src/presentation/widgets/stream_elements/se_overlays.dart b/lib/src/presentation/widgets/stream_elements/se_overlays.dart index ae61890d..341aabc1 100644 --- a/lib/src/presentation/widgets/stream_elements/se_overlays.dart +++ b/lib/src/presentation/widgets/stream_elements/se_overlays.dart @@ -34,10 +34,15 @@ class SeOverlays extends GetView { } } -Widget _overlayRow(StreamelementsViewController controller, - SeOverlay overlay, BuildContext context) { - String overlayUrl = - 'https://streamelements.com/overlay/${overlay.id}/${controller.overlayToken}'; +Widget _overlayRow(StreamelementsViewController controller, SeOverlay overlay, + BuildContext context) { + String? overlayUrl; + Widget? webpage; + if (controller.overlayToken != null) { + overlayUrl = + 'https://streamelements.com/overlay/${overlay.id}/${controller.overlayToken}'; + webpage = WebPageView(overlay.name, overlayUrl); + } return Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondary, @@ -54,10 +59,23 @@ Widget _overlayRow(StreamelementsViewController controller, Expanded( child: Text(overlay.name), ), - InkWell( - onTap: (() => {}), - child: Icon(Icons.preview), - ), + webpage != null + ? InkWell( + onTap: (() => { + Get.defaultDialog( + title: 'Overlay', + titleStyle: const TextStyle(color: Colors.white), + backgroundColor: const Color(0xFF0e0e10), + buttonColor: const Color(0xFF9147ff), + cancelTextColor: const Color(0xFF9147ff), + textCancel: "return".tr, + radius: 10, + content: SizedBox(width:384, height: 216, child: webpage!), + ) + }), + child: const Icon(Icons.preview), + ) + : Container(), const SizedBox( width: 10, ), @@ -67,10 +85,9 @@ Widget _overlayRow(StreamelementsViewController controller, ), ], ), - Visibility( - visible: false, - child: WebPageView(overlay.name, overlayUrl), - ), + webpage != null + ? SizedBox(width: 0, height: 0, child: webpage) + : Container(), ], ), ); From d460c36b4bca08ead3f320d60fa10e7194c2fc6f Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 15:36:16 +0900 Subject: [PATCH 28/30] linting --- lib/src/presentation/widgets/chats/select_channel_dialog.dart | 2 +- lib/src/presentation/widgets/settings/chats_joined.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/presentation/widgets/chats/select_channel_dialog.dart b/lib/src/presentation/widgets/chats/select_channel_dialog.dart index 725e67db..0e7a6944 100644 --- a/lib/src/presentation/widgets/chats/select_channel_dialog.dart +++ b/lib/src/presentation/widgets/chats/select_channel_dialog.dart @@ -17,7 +17,7 @@ class SelectChannelDialog extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return SizedBox( width: 200, height: 60, child: ListView.separated( diff --git a/lib/src/presentation/widgets/settings/chats_joined.dart b/lib/src/presentation/widgets/settings/chats_joined.dart index cb9cba29..5aa007eb 100644 --- a/lib/src/presentation/widgets/settings/chats_joined.dart +++ b/lib/src/presentation/widgets/settings/chats_joined.dart @@ -282,7 +282,7 @@ class ChatsJoined extends GetView { var uuid = const Uuid(); ChatGroup newGroup = ChatGroup( id: uuid.v4(), - channels: [], + channels: const [], ); List? groups = []; groups.addAll(controller From f85a3eb6ffa3ef7603aabe423899b38f71763f2c Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 16:00:24 +0900 Subject: [PATCH 29/30] Mute and unmute SE overlays --- .../stream_elements_settings_dto.dart | 6 ++++ lib/src/domain/entities/settings.dart | 1 + .../settings/stream_elements_settings.dart | 6 ++++ .../widgets/stream_elements/se_overlays.dart | 29 +++++++++++++++---- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/src/data/entities/settings/stream_elements_settings_dto.dart b/lib/src/data/entities/settings/stream_elements_settings_dto.dart index 237f680e..872e648e 100644 --- a/lib/src/data/entities/settings/stream_elements_settings_dto.dart +++ b/lib/src/data/entities/settings/stream_elements_settings_dto.dart @@ -12,6 +12,7 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { required super.showMerchActivity, required super.jwt, required super.overlayToken, + required super.mutedOverlays, }); @override @@ -25,6 +26,7 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { 'showMerchActivity': showMerchActivity, 'jwt': jwt, 'overlayToken': overlayToken, + 'mutedOverlays': mutedOverlays, }; factory StreamElementsSettingsDTO.fromJson(Map map) { @@ -61,6 +63,10 @@ class StreamElementsSettingsDTO extends StreamElementsSettings { const Settings.defaultSettings().streamElementsSettings!.jwt, overlayToken: map['overlayToken'] ?? const Settings.defaultSettings().streamElementsSettings!.overlayToken, + mutedOverlays: (List.from(map['mutedOverlays'] ?? + const Settings.defaultSettings() + .streamElementsSettings! + .mutedOverlays)), ); } } diff --git a/lib/src/domain/entities/settings.dart b/lib/src/domain/entities/settings.dart index e1aefa6d..505227e5 100644 --- a/lib/src/domain/entities/settings.dart +++ b/lib/src/domain/entities/settings.dart @@ -103,6 +103,7 @@ class Settings extends Equatable { showMerchActivity: true, jwt: null, overlayToken: null, + mutedOverlays: [], ), //TTS SETTINGS diff --git a/lib/src/domain/entities/settings/stream_elements_settings.dart b/lib/src/domain/entities/settings/stream_elements_settings.dart index cc657b79..ffaac547 100644 --- a/lib/src/domain/entities/settings/stream_elements_settings.dart +++ b/lib/src/domain/entities/settings/stream_elements_settings.dart @@ -10,6 +10,7 @@ class StreamElementsSettings extends Equatable { final bool showMerchActivity; final String? jwt; final String? overlayToken; + final List mutedOverlays; const StreamElementsSettings({ required this.showFollowerActivity, @@ -21,6 +22,7 @@ class StreamElementsSettings extends Equatable { required this.showMerchActivity, required this.jwt, required this.overlayToken, + required this.mutedOverlays, }); @override @@ -35,6 +37,7 @@ class StreamElementsSettings extends Equatable { showMerchActivity, jwt, overlayToken, + mutedOverlays, ]; } @@ -48,6 +51,7 @@ class StreamElementsSettings extends Equatable { 'showMerchActivity': showMerchActivity, 'jwt': jwt, 'overlayToken': overlayToken, + 'mutedOverlays': mutedOverlays, }; @override @@ -63,6 +67,7 @@ class StreamElementsSettings extends Equatable { bool? showMerchActivity, String? jwt, String? overlayToken, + List? mutedOverlays, }) { return StreamElementsSettings( showFollowerActivity: showFollowerActivity ?? this.showFollowerActivity, @@ -75,6 +80,7 @@ class StreamElementsSettings extends Equatable { showMerchActivity: showMerchActivity ?? this.showMerchActivity, jwt: jwt ?? this.jwt, overlayToken: overlayToken ?? this.overlayToken, + mutedOverlays: mutedOverlays ?? this.mutedOverlays, ); } } diff --git a/lib/src/presentation/widgets/stream_elements/se_overlays.dart b/lib/src/presentation/widgets/stream_elements/se_overlays.dart index 341aabc1..7554e05c 100644 --- a/lib/src/presentation/widgets/stream_elements/se_overlays.dart +++ b/lib/src/presentation/widgets/stream_elements/se_overlays.dart @@ -38,7 +38,10 @@ Widget _overlayRow(StreamelementsViewController controller, SeOverlay overlay, BuildContext context) { String? overlayUrl; Widget? webpage; - if (controller.overlayToken != null) { + bool isMuted = controller + .homeViewController.settings.value.streamElementsSettings!.mutedOverlays + .contains(overlay.id); + if (controller.overlayToken != null && isMuted == false) { overlayUrl = 'https://streamelements.com/overlay/${overlay.id}/${controller.overlayToken}'; webpage = WebPageView(overlay.name, overlayUrl); @@ -70,7 +73,8 @@ Widget _overlayRow(StreamelementsViewController controller, SeOverlay overlay, cancelTextColor: const Color(0xFF9147ff), textCancel: "return".tr, radius: 10, - content: SizedBox(width:384, height: 216, child: webpage!), + content: SizedBox( + width: 384, height: 216, child: webpage!), ) }), child: const Icon(Icons.preview), @@ -80,12 +84,27 @@ Widget _overlayRow(StreamelementsViewController controller, SeOverlay overlay, width: 10, ), InkWell( - onTap: (() => {}), - child: Icon(Icons.volume_up), + onTap: () { + List mutedList = controller.homeViewController.settings + .value.streamElementsSettings!.mutedOverlays; + if (isMuted) { + mutedList.removeWhere((element) => element == overlay.id); + } else { + mutedList.add(overlay.id); + } + controller.homeViewController.settings.value = + controller.homeViewController.settings.value.copyWith( + streamElementsSettings: controller.homeViewController + .settings.value.streamElementsSettings! + .copyWith(mutedOverlays: mutedList)); + controller.homeViewController.saveSettings(); + controller.overlays.refresh(); + }, + child: Icon(isMuted ? Icons.volume_mute : Icons.volume_up), ), ], ), - webpage != null + controller.overlayToken != null ? SizedBox(width: 0, height: 0, child: webpage) : Container(), ], From 918d1f8f2111166b0ca32b25da9580f20c1e2995 Mon Sep 17 00:00:00 2001 From: LezdCS Date: Wed, 22 May 2024 16:07:20 +0900 Subject: [PATCH 30/30] version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 7d32e9f8..6a1d5d5b 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: 2.0.0+50 +version: 2.1.0+52 environment: sdk: '>=2.19.0-0 <4.0.0'