diff --git a/lib/api/bttv_api.dart b/lib/api/bttv_api.dart new file mode 100644 index 00000000..7dab6c23 --- /dev/null +++ b/lib/api/bttv_api.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http/http.dart' as http; + +class BTTV { + /// Returns a map of global BTTV emotes to their URL. + static Future?> getEmotesGlobal() async { + final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body) as List; + final List emotes = decoded.map((emote) => EmoteBTTVGlobal.fromJson(emote)).toList(); + + final emoteToUrl = {}; + for (final emote in emotes) { + emoteToUrl[emote.code] = 'https://cdn.betterttv.net/emote/${emote.id}/3x'; + } + + return emoteToUrl; + } else { + debugPrint('Failed to get global BTTV emotes. Error code: ${response.statusCode}'); + } + } + + /// Returns a map of a channel's BTTV emotes to their URL. + static Future?> getEmotesChannel({required String id}) async { + final url = Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + final result = EmoteBTTVChannel.fromJson(decoded); + + final emoteToUrl = {}; + for (final emote in result.channelEmotes) { + emoteToUrl[emote.code] = 'https://cdn.betterttv.net/emote/${emote.id}/3x'; + } + for (final emote in result.sharedEmotes) { + emoteToUrl[emote.code] = 'https://cdn.betterttv.net/emote/${emote.id}/3x'; + } + + return emoteToUrl; + } else { + debugPrint('Failed to get BTTV emotes for id: $id. Error code: ${response.statusCode}'); + } + } +} diff --git a/lib/api/ffz_api.dart b/lib/api/ffz_api.dart new file mode 100644 index 00000000..b6e9d677 --- /dev/null +++ b/lib/api/ffz_api.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http/http.dart' as http; + +class FFZ { + /// Returns a map of global FFZ emotes to their URL. + static Future?> getEmotesGlobal() async { + final url = Uri.parse('https://api.betterttv.net/3/cached/frankerfacez/emotes/global'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body) as List; + final List emotes = decoded.map((emote) => EmoteFFZ.fromJson(emote)).toList(); + + final emoteToUrl = {}; + for (final emote in emotes) { + emoteToUrl[emote.code] = emote.images.url4x ?? emote.images.url1x; + } + + return emoteToUrl; + } else { + debugPrint('Failed to get global FFZ emotes. Error code: ${response.statusCode}'); + } + } + + /// Returns a map of a channel's FFZ emotes to their URL. + static Future?> getEmotesChannel({required String id}) async { + final url = Uri.parse('https://api.betterttv.net/3/cached/frankerfacez/users/twitch/$id'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body) as List; + final List emotes = decoded.map((emote) => EmoteFFZ.fromJson(emote)).toList(); + + final emoteToUrl = {}; + for (final emote in emotes) { + emoteToUrl[emote.code] = emote.images.url4x ?? emote.images.url1x; + } + + return emoteToUrl; + } else { + debugPrint('Failed to get FFZ emotes for id: $id. Error code: ${response.statusCode}'); + } + } +} diff --git a/lib/api/seventv_api.dart b/lib/api/seventv_api.dart new file mode 100644 index 00000000..13041450 --- /dev/null +++ b/lib/api/seventv_api.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:http/http.dart' as http; + +// A class for general requests. +class SevenTV { + /// Returns a map of global 7TV emotes to their URL. + static Future?> getEmotesGlobal() async { + final url = Uri.parse('https://api.7tv.app/v2/emotes/global'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body) as List; + final List emotes = decoded.map((emote) => Emote7TV.fromJson(emote)).toList(); + + final emoteToUrl = {}; + for (final emote in emotes) { + emoteToUrl[emote.name] = emote.urls[3][1]; + } + return emoteToUrl; + } else { + debugPrint('Failed to get global 7TV emotes. Error code: ${response.statusCode}'); + } + } + + /// Returns a map of a channel's 7TV emotes to their URL. + static Future?> getEmotesChannel({required String user}) async { + final url = Uri.parse('https://api.7tv.app/v2/users/$user/emotes'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body) as List; + final List emotes = decoded.map((emote) => Emote7TV.fromJson(emote)).toList(); + + final emoteToUrl = {}; + for (final emote in emotes) { + emoteToUrl[emote.name] = emote.urls[3][1]; + } + return emoteToUrl; + } else { + debugPrint('Failed to get channel 7TV emotes. Error code: ${response.statusCode}'); + } + } +} diff --git a/lib/api/twitch_api.dart b/lib/api/twitch_api.dart new file mode 100644 index 00000000..2cc7427b --- /dev/null +++ b/lib/api/twitch_api.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/badges.dart'; +import 'package:frosty/models/channel.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:frosty/models/user.dart'; +import 'package:http/http.dart' as http; + +class Twitch { + static Future?> getEmotesGlobal({required Map? headers}) async { + final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global'); + final response = await http.get(url, headers: headers); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body)['data'] as List; + final List emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + + final emoteToUrl = {}; + for (final emote in emotes) { + emoteToUrl[emote.name] = 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0'; + } + + return emoteToUrl; + } else { + debugPrint('Failed to get global Twitch emotes. Error code: ${response.statusCode}'); + } + } + + /// Returns a map of a channel's Twitch emotes to their URL. + static Future?> getEmotesChannel({required String id, required Map? headers}) async { + final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes?broadcaster_id=$id'); + final response = await http.get(url, headers: headers); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body)['data'] as List; + final List emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); + + final emoteToUrl = {}; + for (final emote in emotes) { + emoteToUrl[emote.name] = 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0'; + } + + return emoteToUrl; + } else { + debugPrint('Failed to get Twitch emotes for id: $id. Error code: ${response.statusCode}'); + } + } + + /// Returns a map of global Twitch badges to their URL. + static Future?> getBadgesGlobal({required Map? headers}) async { + final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global'); + final response = await http.get(url, headers: headers); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body)['data'] as List; + final List badges = decoded.map((emote) => BadgesTwitch.fromJson(emote)).toList(); + + final badgeToUrl = {}; + for (final badge in badges) { + for (final badgeVersion in badge.versions) { + badgeToUrl['${badge.setId}/${badgeVersion.id}'] = badgeVersion.imageUrl4x; + } + } + + return badgeToUrl; + } else { + debugPrint('Failed to get global Twitch badges. Error code: ${response.statusCode}'); + } + } + + /// Returns a map of a channel's Twitch badges to their URL. + static Future?> getBadgesChannel({required String id, required Map? headers}) async { + final url = Uri.parse('https://api.twitch.tv/helix/chat/badges?broadcaster_id=$id'); + final response = await http.get(url, headers: headers); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body)['data'] as List; + final List badges = decoded.map((emote) => BadgesTwitch.fromJson(emote)).toList(); + + final badgeToUrl = {}; + for (final badge in badges) { + for (final badgeVersion in badge.versions) { + badgeToUrl['${badge.setId}/${badgeVersion.id}'] = badgeVersion.imageUrl4x; + } + } + + return badgeToUrl; + } else { + debugPrint('Failed to get Twitch badges for id: $id. Error code: ${response.statusCode}'); + } + } + + /// Returns the user's info given their token (headers) + static Future getUserInfo({required Map? headers}) async { + final response = await http.get(Uri.parse('https://api.twitch.tv/helix/users'), headers: headers); + final userData = jsonDecode(response.body)['data'] as List; + + return UserTwitch.fromJson(userData.first); + } + + /// Returns a token for an anonymous user. + static Future getDefaultToken() async { + debugPrint('Getting default token...'); + + final url = Uri( + scheme: 'https', + host: 'id.twitch.tv', + path: '/oauth2/token', + queryParameters: { + 'client_id': clientId, + 'client_secret': secret, + 'grant_type': 'client_credentials', + }, + ); + + final response = await http.post(url); + final defaultToken = jsonDecode(response.body)['access_token']; + + return defaultToken; + } + + /// Returns the validity of the given token + static Future validateToken({required String token}) async { + debugPrint('Validating token...'); + final response = await http.get(Uri.parse('https://id.twitch.tv/oauth2/validate'), headers: {'Authorization': 'Bearer $token'}); + if (response.statusCode == 200) { + debugPrint('Token validated!'); + return true; + } + + debugPrint('Token invalidated :('); + return false; + } + + /// Returns a map with top 10 streamers and a cursor for further requests. + static Future?> getTopChannels({required Map? headers, required String? cursor}) async { + final Uri uri; + + if (cursor == null) { + uri = Uri.parse('https://api.twitch.tv/helix/streams?first=10'); + } else { + uri = Uri.parse('https://api.twitch.tv/helix/streams?first=10&after=$cursor'); + } + + final response = await http.get(uri, headers: headers); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + final data = decoded['data'] as List; + + return {'channels': data.map((channel) => Channel.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']}; + } else { + debugPrint('Failed to update top channels'); + } + } + + /// Returns a map with the given user's top 10 followed streamers and a cursor for further requests. + static Future?> getFollowedChannels({required String id, required Map? headers, required String? cursor}) async { + final Uri uri; + + if (cursor == null) { + uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?first=10&user_id=$id'); + } else { + uri = Uri.parse('https://api.twitch.tv/helix/streams/followed?user_id=$id&first=10&after=$cursor'); + } + + final response = await http.get(uri, headers: headers); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + final data = decoded['data'] as List; + + return {'channels': data.map((channel) => Channel.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']}; + } else { + debugPrint('Failed to update followed channels'); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 16873678..2bb657b0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:frosty/providers/authentication_provider.dart'; -import 'package:frosty/providers/settings_provider.dart'; import 'package:frosty/screens/home.dart'; -import 'package:frosty/providers/channel_list_provider.dart'; -import 'package:provider/provider.dart'; +import 'package:frosty/stores/auth_store.dart'; +import 'package:get_it/get_it.dart'; void main() { + GetIt.I.registerSingleton(AuthStore()); + runApp(const MyApp()); } @@ -14,29 +14,23 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => AuthenticationProvider()), - ChangeNotifierProxyProvider( - create: (_) => SettingsProvider(), - update: (context, auth, settingsProvider) { - return SettingsProvider(); + final auth = GetIt.I(); + return MaterialApp( + title: 'Frosty', + theme: ThemeData( + primarySwatch: Colors.blue, + brightness: Brightness.dark, + ), + home: Scaffold( + body: FutureBuilder( + future: auth.init(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return const Home(); + } + return const Center(child: CircularProgressIndicator()); }, ), - ChangeNotifierProxyProvider( - create: (_) => ChannelListProvider(), - update: (context, auth, channelListProvider) { - return ChannelListProvider(id: auth.user?.id); - }, - ), - ], - child: MaterialApp( - title: 'Frosty', - theme: ThemeData( - primarySwatch: Colors.blue, - brightness: Brightness.dark, - ), - home: const Home(), ), ); } diff --git a/lib/models/emotes.dart b/lib/models/emotes.dart index d440e12c..967fb883 100644 --- a/lib/models/emotes.dart +++ b/lib/models/emotes.dart @@ -2,8 +2,6 @@ import 'package:json_annotation/json_annotation.dart'; part 'emotes.g.dart'; -// TODO: Add 7TV emotes: https://github.com/SevenTV/ServerGo/blob/master/docs/rest-api.md - // * Twitch Emotes * @JsonSerializable(createToJson: false) class ImagesTwitch { diff --git a/lib/providers/authentication_provider.dart b/lib/providers/authentication_provider.dart deleted file mode 100644 index 9850452c..00000000 --- a/lib/providers/authentication_provider.dart +++ /dev/null @@ -1,138 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/models/user.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:http/http.dart' as http; -import 'package:flutter_web_auth/flutter_web_auth.dart'; - -class AuthenticationProvider extends ChangeNotifier { - static String? _token; - static String? get token => _token; - - static Map? authHeaders; - - final _storage = const FlutterSecureStorage(); - final _secret = secret; - final _clientId = clientId; - - var _isLoggedIn = false; - bool get isLoggedIn => _isLoggedIn; - - var _tokenIsValid = false; - - UserTwitch? user; - - /// Initialize by retrieving a token if it does not already exist. - Future init() async { - // Read and set the currently stored user token, if any. - _token = await _storage.read(key: 'USER_TOKEN'); - - // If the token does not exist, get the default token. - // Otherwise, log in and get the user info. - if (token != null) { - _isLoggedIn = true; - authHeaders = {'Authorization': 'Bearer $token', 'Client-Id': _clientId}; - await _getUserInfo(); - } else { - await _getDefaultToken(); - } - - // Set the auth headers for future requests and validate the token. - authHeaders = {'Authorization': 'Bearer $token', 'Client-Id': _clientId}; - await _validateToken(); - - debugPrint('Token is valid: $_tokenIsValid'); - - debugPrint('Created auth provider'); - } - - /// Returns a token for an anonymous user. - Future _getDefaultToken() async { - debugPrint('Getting default token...'); - _token = await _storage.read(key: 'DEFAULT_TOKEN'); - - if (token != null) return; - - final url = Uri( - scheme: 'https', - host: 'id.twitch.tv', - path: '/oauth2/token', - queryParameters: { - 'client_id': _clientId, - 'client_secret': _secret, - 'grant_type': 'client_credentials', - }, - ); - - final response = await http.post(url); - final defaultToken = jsonDecode(response.body)['access_token']; - - await _storage.write(key: 'DEFAULT_TOKEN', value: defaultToken); - - _token = defaultToken; - } - - Future _validateToken() async { - debugPrint('Validating token...'); - final response = await http.get(Uri.parse('https://id.twitch.tv/oauth2/validate'), headers: {'Authorization': 'Bearer $token'}); - if (response.statusCode == 200) { - debugPrint('Token validated!'); - _tokenIsValid = true; - return; - } - - debugPrint('Token invalidated :('); - _tokenIsValid = false; - - notifyListeners(); - } - - Future _getUserInfo() async { - final response = await http.get(Uri.parse('https://api.twitch.tv/helix/users'), headers: authHeaders); - final userData = jsonDecode(response.body)['data'] as List; - - user = UserTwitch.fromJson(userData.first); - } - - Future login() async { - final loginUrl = Uri( - scheme: 'https', - host: 'id.twitch.tv', - path: '/oauth2/authorize', - queryParameters: { - 'client_id': _clientId, - 'redirect_uri': 'auth://', - 'response_type': 'token', - 'scope': 'chat:read chat:edit user:read:follows', - }, - ); - - try { - final result = await FlutterWebAuth.authenticate(url: loginUrl.toString(), callbackUrlScheme: 'auth', preferEphemeral: true); - - final fragment = Uri.parse(result).fragment; - - _token = fragment.substring(fragment.indexOf('=') + 1, fragment.indexOf('&')); - await _storage.write(key: 'USER_TOKEN', value: token); - - _isLoggedIn = true; - authHeaders = {'Authorization': 'Bearer $token', 'Client-Id': _clientId}; - - await _getUserInfo(); - - notifyListeners(); - } catch (error) { - debugPrint('Login failed due to $error'); - } - } - - void logout() async { - await _getDefaultToken(); - _isLoggedIn = false; - - await _storage.delete(key: 'USER_TOKEN'); - debugPrint('Succesfully logged out'); - notifyListeners(); - } -} diff --git a/lib/providers/channel_list_provider.dart b/lib/providers/channel_list_provider.dart deleted file mode 100644 index 820d33df..00000000 --- a/lib/providers/channel_list_provider.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:frosty/constants.dart'; -import 'package:frosty/providers/authentication_provider.dart'; -import 'package:http/http.dart' as http; -import 'package:frosty/models/channel.dart'; - -class ChannelListProvider extends ChangeNotifier { - var topChannels = []; - var followedChannels = []; - var isLoading = false; - - String? topChannelsCurrentCursor; - String? followedChannelsCurrentCursor; - - final token = AuthenticationProvider.token; - final String? id; - - ChannelListProvider({this.id}) { - if (token != null) { - updateTopChannels(); - } - if (id != null) { - updateFollowedChannels(); - } - } - - String? currentCursor({required Category category}) { - switch (category) { - case Category.top: - return topChannelsCurrentCursor; - case Category.followed: - return followedChannelsCurrentCursor; - } - } - - List channels({required Category category}) { - switch (category) { - case Category.top: - return topChannels; - case Category.followed: - return followedChannels; - } - } - - Future update({required Category category}) async { - switch (category) { - case Category.top: - await updateTopChannels(); - break; - case Category.followed: - await updateFollowedChannels(); - break; - } - } - - /// Returns the top 10 streamers and a cursor for further requests. - Future updateTopChannels() async { - final url = Uri.parse('https://api.twitch.tv/helix/streams?first=10'); - final headers = {'Authorization': 'Bearer $token', 'Client-Id': clientId}; - - final response = await http.get(url, headers: headers); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final data = decoded['data'] as List; - - final result = {'channels': data.map((channel) => Channel.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']}; - - topChannels = result['channels']; - topChannelsCurrentCursor = result['cursor']; - } else { - debugPrint('Failed to update top channels'); - } - notifyListeners(); - } - - Future getMoreChannels({required Category category}) async { - isLoading = true; - Uri url; - - switch (category) { - case Category.top: - url = Uri.parse('https://api.twitch.tv/helix/streams?first=10&after=$topChannelsCurrentCursor'); - break; - case Category.followed: - url = Uri.parse('https://api.twitch.tv/helix/streams/followed?user_id=$id&first=10&after=$followedChannelsCurrentCursor'); - break; - } - - final headers = {'Authorization': 'Bearer $token', 'Client-Id': clientId}; - final response = await http.get(url, headers: headers); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final data = decoded['data'] as List; - - final result = {'channels': data.map((channel) => Channel.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']}; - - switch (category) { - case Category.top: - topChannels.addAll(result['channels']); - topChannelsCurrentCursor = result['cursor']; - break; - case Category.followed: - followedChannels.addAll(result['channels']); - followedChannelsCurrentCursor = result['cursor']; - debugPrint(followedChannelsCurrentCursor); - break; - } - } else { - debugPrint('Failed to get more channels'); - } - isLoading = false; - notifyListeners(); - } - - Future updateFollowedChannels() async { - final url = Uri.parse('https://api.twitch.tv/helix/streams/followed?first=10&user_id=$id'); - final headers = {'Authorization': 'Bearer $token', 'Client-Id': clientId}; - - final response = await http.get(url, headers: headers); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final data = decoded['data'] as List; - - final result = {'channels': data.map((channel) => Channel.fromJson(channel)).toList(), 'cursor': decoded['pagination']['cursor']}; - - followedChannels = result['channels']; - followedChannelsCurrentCursor = result['cursor']; - } else { - debugPrint('Failed to update followed channels'); - } - notifyListeners(); - } -} - -enum Category { top, followed } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart deleted file mode 100644 index ba7cf13c..00000000 --- a/lib/providers/settings_provider.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class SettingsProvider extends ChangeNotifier {} diff --git a/lib/screens/channel_list.dart b/lib/screens/channel_list.dart index 76e844a5..9fc41cce 100644 --- a/lib/screens/channel_list.dart +++ b/lib/screens/channel_list.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:frosty/providers/authentication_provider.dart'; -import 'package:frosty/providers/channel_list_provider.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/stores/channel_list_store.dart'; import 'package:frosty/widgets/channel_card.dart'; -import 'package:provider/provider.dart'; class ChannelList extends StatefulWidget { - final Category category; + final ChannelCategory category; + final ChannelListStore channelListStore; - const ChannelList({Key? key, required this.category}) : super(key: key); + const ChannelList({Key? key, required this.category, required this.channelListStore}) : super(key: key); @override _ChannelListState createState() => _ChannelListState(); @@ -16,24 +16,26 @@ class ChannelList extends StatefulWidget { class _ChannelListState extends State with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { + debugPrint('build channel list'); super.build(context); - return Consumer2( - builder: (context, auth, viewModel, child) { - final channels = viewModel.channels(category: widget.category); - return RefreshIndicator( - child: ListView.builder( + + return RefreshIndicator( + child: Observer( + builder: (_) { + final channels = widget.channelListStore.channels(category: widget.category); + return ListView.builder( itemCount: channels.length, padding: const EdgeInsets.all(5.0), itemBuilder: (context, index) { - if (index > channels.length / 2 && viewModel.isLoading == false && viewModel.currentCursor(category: widget.category) != null) { - viewModel.getMoreChannels(category: widget.category); + if (index > channels.length / 2 && widget.channelListStore.hasMore(category: widget.category)) { + widget.channelListStore.getChannels(category: widget.category); } - return ChannelCard(channelInfo: channels[index]); + return ChannelCard(channelInfo: channels.elementAt(index)); }, - ), - onRefresh: () => viewModel.update(category: widget.category), - ); - }, + ); + }, + ), + onRefresh: () => widget.channelListStore.refresh(category: widget.category), ); } diff --git a/lib/screens/home.dart b/lib/screens/home.dart index b4e30342..628345d1 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:frosty/providers/authentication_provider.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/screens/settings.dart'; -import 'package:frosty/providers/channel_list_provider.dart'; -import 'package:provider/provider.dart'; +import 'package:frosty/stores/auth_store.dart'; +import 'package:frosty/stores/channel_list_store.dart'; +import 'package:get_it/get_it.dart'; import 'channel_list.dart'; class Home extends StatelessWidget { @@ -10,63 +11,59 @@ class Home extends StatelessWidget { @override Widget build(BuildContext context) { - final auth = context.watch(); + final auth = GetIt.I(); debugPrint('build home'); - return FutureBuilder( - future: auth.init(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return DefaultTabController( - length: auth.isLoggedIn ? 3 : 2, - child: Scaffold( - appBar: AppBar( - title: const Text('Frosty for Twitch'), - bottom: TabBar( - tabs: [ - const Tab(text: 'Top'), - if (auth.isLoggedIn) const Tab(text: 'Followed'), - const Tab(text: 'Categories'), - ], - ), - actions: [ - IconButton( - icon: const Icon(Icons.settings), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const Settings(); - }, - ), - ); - }, - ) - ], - ), - body: TabBarView( - physics: const NeverScrollableScrollPhysics(), - children: [ - const ChannelList( - category: Category.top, - ), - if (auth.isLoggedIn) - const ChannelList( - category: Category.followed, - ), - const Center( - child: Text('Games'), - ), + return Observer( + builder: (_) { + final channelListStore = ChannelListStore(auth: auth); + debugPrint('rebuild tab controller'); + return DefaultTabController( + length: auth.isLoggedIn ? 3 : 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Frosty for Twitch'), + bottom: TabBar( + tabs: [ + const Tab(text: 'Top'), + if (auth.isLoggedIn) const Tab(text: 'Followed'), + const Tab(text: 'Categories'), ], ), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return const Settings(); + }, + ), + ); + }, + ) + ], ), - ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } + body: TabBarView( + children: [ + ChannelList( + category: ChannelCategory.top, + channelListStore: channelListStore, + ), + if (auth.isLoggedIn) + ChannelList( + category: ChannelCategory.followed, + channelListStore: channelListStore, + ), + const Center( + child: Text('Games'), + ), + ], + ), + ), + ); }, ); } diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index cbbeb19a..d4f65580 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,27 +1,29 @@ import 'package:flutter/material.dart'; -import 'package:frosty/providers/authentication_provider.dart'; -import 'package:frosty/providers/settings_provider.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:frosty/stores/auth_store.dart'; +import 'package:get_it/get_it.dart'; class Settings extends StatelessWidget { const Settings({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - return Consumer2( - builder: (context, settings, auth, child) { - return Scaffold( - appBar: AppBar( - title: const Text('Settings'), - ), - body: Center( + debugPrint('settings'); + final auth = GetIt.I(); + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: Observer( + builder: (_) { + return Center( child: ElevatedButton( child: auth.isLoggedIn ? Text(auth.user!.displayName) : const Text('Login'), onPressed: () => auth.isLoggedIn ? auth.logout() : auth.login(), ), - ), - ); - }, + ); + }, + ), ); } } diff --git a/lib/screens/video_chat.dart b/lib/screens/video_chat.dart index 3022cc07..6fb028bf 100644 --- a/lib/screens/video_chat.dart +++ b/lib/screens/video_chat.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/widgets/chat.dart'; @@ -10,17 +11,19 @@ class VideoChat extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - child: Column( - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: Video(channelName: channelInfo.userLogin), - ), - Expanded( - child: Chat(channelInfo: channelInfo), - ) - ], + return Scaffold( + body: SafeArea( + child: Column( + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: Video(channelName: channelInfo.userLogin), + ), + Expanded( + child: Chat(channelInfo: channelInfo), + ) + ], + ), ), ); } diff --git a/lib/stores/auth_store.dart b/lib/stores/auth_store.dart new file mode 100644 index 00000000..80792912 --- /dev/null +++ b/lib/stores/auth_store.dart @@ -0,0 +1,124 @@ +import 'package:frosty/api/twitch_api.dart'; +import 'package:mobx/mobx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:frosty/constants.dart'; +import 'package:frosty/models/user.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_web_auth/flutter_web_auth.dart'; + +part 'auth_store.g.dart'; + +class AuthStore = _AuthBase with _$AuthStore; + +abstract class _AuthBase with Store { + // Secure storage to store tokens + final _storage = const FlutterSecureStorage(); + + // The current token + @observable + String? _token; + String? get token => _token; + + // Whether the user is logged in or not + @observable + var _isLoggedIn = false; + bool get isLoggedIn => _isLoggedIn; + + // Whether the token is valid or not + @observable + var _tokenIsValid = false; + + // The current user's info + @observable + UserTwitch? _user; + UserTwitch? get user => _user; + + // Authentication headers for Twitch API requests + @computed + Map get headersTwitch => {'Authorization': 'Bearer $_token', 'Client-Id': clientId}; + + /// Initialize by retrieving a token if it does not already exist. + @action + Future init() async { + // Read and set the currently stored user token, if any. + _token = await _storage.read(key: 'USER_TOKEN'); + + // If the token does not exist, get the default token. + // Otherwise, log in and get the user info. + if (_token == null) { + // Retrieve the currently stored default token if it exists + _token = await _storage.read(key: 'DEFAULT_TOKEN'); + // If the token does not exist, get a new token and store it + if (_token == null) { + _token = await Twitch.getDefaultToken(); + await _storage.write(key: 'DEFAULT_TOKEN', value: _token); + } + } else { + _isLoggedIn = true; + _user = await Twitch.getUserInfo(headers: headersTwitch); + } + + // Validate the token + _tokenIsValid = await Twitch.validateToken(token: _token!); + + // If the token is invalid, logout + if (!_tokenIsValid) { + logout(); + return; + } + + debugPrint('Token is valid: $_tokenIsValid'); + debugPrint('Created auth provider'); + } + + /// Initiates the OAuth sign-in process and updates fields accordingly upon successful login + @action + Future login() async { + // Create the OAuth sign-in URI + final loginUrl = Uri( + scheme: 'https', + host: 'id.twitch.tv', + path: '/oauth2/authorize', + queryParameters: { + 'client_id': clientId, + 'redirect_uri': 'auth://', + 'response_type': 'token', + 'scope': 'chat:read chat:edit user:read:follows', + }, + ); + + try { + // Retrieve the OAuth redirect URI + final result = await FlutterWebAuth.authenticate(url: loginUrl.toString(), callbackUrlScheme: 'auth', preferEphemeral: true); + + // Parse the user token from the redirect URI fragment + final fragment = Uri.parse(result).fragment; + _token = fragment.substring(fragment.indexOf('=') + 1, fragment.indexOf('&')); + + // Store the user token + await _storage.write(key: 'USER_TOKEN', value: _token); + + // Retrieve the user info and set it + _user = await Twitch.getUserInfo(headers: headersTwitch); + + // Set the login status to logged in + _isLoggedIn = true; + } catch (error) { + debugPrint('Login failed due to $error'); + } + } + + // Logs out the current user and updates fields accordingly + @action + Future logout() async { + // If the default token already exists, set it. Otherwise, get a new default token. + _token = await _storage.read(key: 'DEFAULT_TOKEN') ?? await Twitch.getDefaultToken(); + + // Clear the user info + _user = null; + + // Set the login status to logged out + _isLoggedIn = false; + debugPrint('Successfully logged out'); + } +} diff --git a/lib/stores/auth_store.g.dart b/lib/stores/auth_store.g.dart new file mode 100644 index 00000000..7e4b53cd --- /dev/null +++ b/lib/stores/auth_store.g.dart @@ -0,0 +1,107 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic + +mixin _$AuthStore on _AuthBase, Store { + Computed>? _$headersTwitchComputed; + + @override + Map get headersTwitch => (_$headersTwitchComputed ??= + Computed>(() => super.headersTwitch, + name: '_AuthBase.headersTwitch')) + .value; + + final _$_tokenAtom = Atom(name: '_AuthBase._token'); + + @override + String? get _token { + _$_tokenAtom.reportRead(); + return super._token; + } + + @override + set _token(String? value) { + _$_tokenAtom.reportWrite(value, super._token, () { + super._token = value; + }); + } + + final _$_isLoggedInAtom = Atom(name: '_AuthBase._isLoggedIn'); + + @override + bool get _isLoggedIn { + _$_isLoggedInAtom.reportRead(); + return super._isLoggedIn; + } + + @override + set _isLoggedIn(bool value) { + _$_isLoggedInAtom.reportWrite(value, super._isLoggedIn, () { + super._isLoggedIn = value; + }); + } + + final _$_tokenIsValidAtom = Atom(name: '_AuthBase._tokenIsValid'); + + @override + bool get _tokenIsValid { + _$_tokenIsValidAtom.reportRead(); + return super._tokenIsValid; + } + + @override + set _tokenIsValid(bool value) { + _$_tokenIsValidAtom.reportWrite(value, super._tokenIsValid, () { + super._tokenIsValid = value; + }); + } + + final _$_userAtom = Atom(name: '_AuthBase._user'); + + @override + UserTwitch? get _user { + _$_userAtom.reportRead(); + return super._user; + } + + @override + set _user(UserTwitch? value) { + _$_userAtom.reportWrite(value, super._user, () { + super._user = value; + }); + } + + final _$initAsyncAction = AsyncAction('_AuthBase.init'); + + @override + Future init() { + return _$initAsyncAction.run(() => super.init()); + } + + final _$loginAsyncAction = AsyncAction('_AuthBase.login'); + + @override + Future login() { + return _$loginAsyncAction.run(() => super.login()); + } + + final _$logoutAsyncAction = AsyncAction('_AuthBase.logout'); + + @override + Future logout() { + return _$logoutAsyncAction.run(() => super.logout()); + } + + @override + String toString() { + return ''' +headersTwitch: ${headersTwitch} + '''; + } +} diff --git a/lib/stores/channel_list_store.dart b/lib/stores/channel_list_store.dart new file mode 100644 index 00000000..fe6ede77 --- /dev/null +++ b/lib/stores/channel_list_store.dart @@ -0,0 +1,100 @@ +import 'package:frosty/api/twitch_api.dart'; +import 'package:mobx/mobx.dart'; +import 'package:frosty/stores/auth_store.dart'; +import 'package:frosty/models/channel.dart'; + +part 'channel_list_store.g.dart'; + +class ChannelListStore = _ChannelListBase with _$ChannelListStore; + +abstract class _ChannelListBase with Store { + @observable + ObservableList topChannels = ObservableList(); + + @observable + ObservableList followedChannels = ObservableList(); + + bool isLoading = false; + + String? topChannelsCurrentCursor; + + String? followedChannelsCurrentCursor; + + ObservableList channels({required ChannelCategory category}) { + switch (category) { + case ChannelCategory.top: + return topChannels; + case ChannelCategory.followed: + return followedChannels; + } + } + + final AuthStore auth; + + _ChannelListBase({required this.auth}) { + getChannels(category: ChannelCategory.top); + + if (auth.user?.id != null) { + getChannels(category: ChannelCategory.followed); + } + } + + bool hasMore({required ChannelCategory category}) { + switch (category) { + case ChannelCategory.top: + return isLoading == false && topChannelsCurrentCursor != null; + case ChannelCategory.followed: + return isLoading == false && followedChannelsCurrentCursor != null; + } + } + + @action + Future refresh({required ChannelCategory category}) async { + switch (category) { + case ChannelCategory.top: + topChannelsCurrentCursor = null; + break; + case ChannelCategory.followed: + followedChannelsCurrentCursor = null; + break; + } + + await getChannels(category: category); + } + + @action + Future getChannels({required ChannelCategory category}) async { + isLoading = true; + + switch (category) { + case ChannelCategory.top: + final newTopChannels = await Twitch.getTopChannels(headers: auth.headersTwitch, cursor: topChannelsCurrentCursor); + + if (newTopChannels != null) { + if (topChannelsCurrentCursor == null) { + topChannels = ObservableList.of(newTopChannels['channels']); + } else { + topChannels.addAll(newTopChannels['channels']); + } + topChannelsCurrentCursor = newTopChannels['cursor']; + } + break; + case ChannelCategory.followed: + final newFollowedChannels = await Twitch.getFollowedChannels(id: auth.user!.id, headers: auth.headersTwitch, cursor: followedChannelsCurrentCursor); + + if (newFollowedChannels != null) { + if (followedChannelsCurrentCursor == null) { + followedChannels = ObservableList.of(newFollowedChannels['channels']); + } else { + followedChannels.addAll(newFollowedChannels['channels']); + } + followedChannelsCurrentCursor = newFollowedChannels['cursor']; + } + break; + } + + isLoading = false; + } +} + +enum ChannelCategory { top, followed } diff --git a/lib/stores/channel_list_store.g.dart b/lib/stores/channel_list_store.g.dart new file mode 100644 index 00000000..3a9d741a --- /dev/null +++ b/lib/stores/channel_list_store.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_list_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic + +mixin _$ChannelListStore on _ChannelListBase, Store { + final _$topChannelsAtom = Atom(name: '_ChannelListBase.topChannels'); + + @override + ObservableList get topChannels { + _$topChannelsAtom.reportRead(); + return super.topChannels; + } + + @override + set topChannels(ObservableList value) { + _$topChannelsAtom.reportWrite(value, super.topChannels, () { + super.topChannels = value; + }); + } + + final _$followedChannelsAtom = + Atom(name: '_ChannelListBase.followedChannels'); + + @override + ObservableList get followedChannels { + _$followedChannelsAtom.reportRead(); + return super.followedChannels; + } + + @override + set followedChannels(ObservableList value) { + _$followedChannelsAtom.reportWrite(value, super.followedChannels, () { + super.followedChannels = value; + }); + } + + final _$refreshAsyncAction = AsyncAction('_ChannelListBase.refresh'); + + @override + Future refresh({required ChannelCategory category}) { + return _$refreshAsyncAction.run(() => super.refresh(category: category)); + } + + final _$getChannelsAsyncAction = AsyncAction('_ChannelListBase.getChannels'); + + @override + Future getChannels({required ChannelCategory category}) { + return _$getChannelsAsyncAction + .run(() => super.getChannels(category: category)); + } + + @override + String toString() { + return ''' +topChannels: ${topChannels}, +followedChannels: ${followedChannels} + '''; + } +} diff --git a/lib/providers/chat_provider.dart b/lib/stores/chat_store.dart similarity index 84% rename from lib/providers/chat_provider.dart rename to lib/stores/chat_store.dart index 9629b4d8..43006080 100644 --- a/lib/providers/chat_provider.dart +++ b/lib/stores/chat_store.dart @@ -1,27 +1,39 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; +import 'package:frosty/api/bttv_api.dart'; +import 'package:frosty/api/ffz_api.dart'; +import 'package:frosty/api/seventv_api.dart'; +import 'package:frosty/api/twitch_api.dart'; import 'package:frosty/models/channel.dart'; -import 'package:frosty/providers/authentication_provider.dart'; -import 'package:frosty/utility/request.dart'; +import 'package:frosty/stores/auth_store.dart'; import 'package:frosty/widgets/chat_message.dart'; +import 'package:mobx/mobx.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -class ChatProvider extends ChangeNotifier { - final Channel channelInfo; +part 'chat_store.g.dart'; +class ChatStore = _ChatStoreBase with _$ChatStore; + +abstract class _ChatStoreBase with Store { + @observable + bool autoScroll = true; + + List messages = []; + + final Channel channelInfo; final channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443')); - final messages = []; - final scrollController = ScrollController(); final _assetToUrl = {}; final _emoteIdToWord = {}; - var autoScroll = true; + final scrollController = ScrollController(); - ChatProvider({required this.channelInfo}) { + final AuthStore auth; + + _ChatStoreBase({required this.auth, required this.channelInfo}) { final commands = [ - 'PASS oauth:${AuthenticationProvider.token}', + 'PASS oauth:${auth.token}', 'NICK justinfan888', 'CAP REQ :twitch.tv/tags', 'CAP REQ :twitch.tv/commands', @@ -37,26 +49,25 @@ class ChatProvider extends ChangeNotifier { scrollController.addListener(() { if (!scrollController.position.atEdge) { autoScroll = false; - notifyListeners(); } else if (scrollController.position.atEdge && scrollController.position.pixels != scrollController.position.minScrollExtent) { autoScroll = true; - notifyListeners(); } }); } - Future getEmotes() async { + @action + Future getAssets() async { final assets = [ - await Request.getEmotesBTTVGlobal(), - await Request.getEmotesBTTVChannel(id: channelInfo.userId), - await Request.getEmotesFFZGlobal(), - await Request.getEmotesFFZChannel(id: channelInfo.userId), - await Request.getEmotesTwitchGlobal(), - await Request.getEmotesTwitchChannel(id: channelInfo.userId), - await Request.getBadgesTwitchGlobal(), - await Request.getBadgesTwitchChannel(id: channelInfo.userId), - await Request.getEmotes7TVGlobal(), - await Request.getEmotes7TVChannel(user: channelInfo.userLogin) + await FFZ.getEmotesGlobal(), + await FFZ.getEmotesChannel(id: channelInfo.userId), + await BTTV.getEmotesGlobal(), + await BTTV.getEmotesChannel(id: channelInfo.userId), + await Twitch.getEmotesGlobal(headers: auth.headersTwitch), + await Twitch.getEmotesChannel(id: channelInfo.userId, headers: auth.headersTwitch), + await Twitch.getBadgesGlobal(headers: auth.headersTwitch), + await Twitch.getBadgesChannel(id: channelInfo.userId, headers: auth.headersTwitch), + await SevenTV.getEmotesGlobal(), + await SevenTV.getEmotesChannel(user: channelInfo.userLogin) ]; for (final map in assets) { @@ -66,6 +77,7 @@ class ChatProvider extends ChangeNotifier { } } + @action void handleWebsocketData(Object? data) { for (final message in data.toString().split('\r\n')) { if (message.startsWith('@')) { @@ -223,6 +235,7 @@ class ChatProvider extends ChangeNotifier { return result; } + @action void resumeScroll() { autoScroll = true; scrollController.jumpTo(scrollController.position.maxScrollExtent); @@ -230,12 +243,6 @@ class ChatProvider extends ChangeNotifier { scrollController.jumpTo(scrollController.position.maxScrollExtent); }); } - - @override - void dispose() { - channel.sink.close(); - super.dispose(); - } } // https://stackoverflow.com/questions/50081213/how-do-i-use-hexadecimal-color-strings-in-flutter diff --git a/lib/stores/chat_store.g.dart b/lib/stores/chat_store.g.dart new file mode 100644 index 00000000..b609919b --- /dev/null +++ b/lib/stores/chat_store.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_store.dart'; + +// ************************************************************************** +// StoreGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic + +mixin _$ChatStore on _ChatStoreBase, Store { + final _$autoScrollAtom = Atom(name: '_ChatStoreBase.autoScroll'); + + @override + bool get autoScroll { + _$autoScrollAtom.reportRead(); + return super.autoScroll; + } + + @override + set autoScroll(bool value) { + _$autoScrollAtom.reportWrite(value, super.autoScroll, () { + super.autoScroll = value; + }); + } + + final _$getAssetsAsyncAction = AsyncAction('_ChatStoreBase.getAssets'); + + @override + Future getAssets() { + return _$getAssetsAsyncAction.run(() => super.getAssets()); + } + + final _$_ChatStoreBaseActionController = + ActionController(name: '_ChatStoreBase'); + + @override + void handleWebsocketData(Object? data) { + final _$actionInfo = _$_ChatStoreBaseActionController.startAction( + name: '_ChatStoreBase.handleWebsocketData'); + try { + return super.handleWebsocketData(data); + } finally { + _$_ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + void resumeScroll() { + final _$actionInfo = _$_ChatStoreBaseActionController.startAction( + name: '_ChatStoreBase.resumeScroll'); + try { + return super.resumeScroll(); + } finally { + _$_ChatStoreBaseActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +autoScroll: ${autoScroll} + '''; + } +} diff --git a/lib/utility/request.dart b/lib/utility/request.dart deleted file mode 100644 index 637cb6fa..00000000 --- a/lib/utility/request.dart +++ /dev/null @@ -1,214 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/foundation.dart'; -import 'package:frosty/models/badges.dart'; -import 'package:frosty/models/emotes.dart'; -import 'package:frosty/providers/authentication_provider.dart'; -import 'package:http/http.dart' as http; - -// A class for general requests. -class Request { - /// Returns a map of global BTTV emotes to their URL. - static Future?> getEmotesBTTVGlobal() async { - final url = Uri.parse('https://api.betterttv.net/3/cached/emotes/global'); - final response = await http.get(url); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final List emotes = decoded.map((emote) => EmoteBTTVGlobal.fromJson(emote)).toList(); - - final emoteToUrl = {}; - for (final emote in emotes) { - emoteToUrl[emote.code] = 'https://cdn.betterttv.net/emote/${emote.id}/3x'; - } - - return emoteToUrl; - } else { - debugPrint('Failed to get global BTTV emotes. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of a channel's BTTV emotes to their URL. - static Future?> getEmotesBTTVChannel({required String id}) async { - final url = Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id'); - final response = await http.get(url); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body); - final result = EmoteBTTVChannel.fromJson(decoded); - - final emoteToUrl = {}; - for (final emote in result.channelEmotes) { - emoteToUrl[emote.code] = 'https://cdn.betterttv.net/emote/${emote.id}/3x'; - } - for (final emote in result.sharedEmotes) { - emoteToUrl[emote.code] = 'https://cdn.betterttv.net/emote/${emote.id}/3x'; - } - - return emoteToUrl; - } else { - debugPrint('Failed to get BTTV emotes for id: $id. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of global FFZ emotes to their URL. - static Future?> getEmotesFFZGlobal() async { - final url = Uri.parse('https://api.betterttv.net/3/cached/frankerfacez/emotes/global'); - final response = await http.get(url); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final List emotes = decoded.map((emote) => EmoteFFZ.fromJson(emote)).toList(); - - final emoteToUrl = {}; - for (final emote in emotes) { - emoteToUrl[emote.code] = emote.images.url4x ?? emote.images.url1x; - } - - return emoteToUrl; - } else { - debugPrint('Failed to get global FFZ emotes. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of a channel's FFZ emotes to their URL. - static Future?> getEmotesFFZChannel({required String id}) async { - final url = Uri.parse('https://api.betterttv.net/3/cached/frankerfacez/users/twitch/$id'); - final response = await http.get(url); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final List emotes = decoded.map((emote) => EmoteFFZ.fromJson(emote)).toList(); - - final emoteToUrl = {}; - for (final emote in emotes) { - emoteToUrl[emote.code] = emote.images.url4x ?? emote.images.url1x; - } - - return emoteToUrl; - } else { - debugPrint('Failed to get FFZ emotes for id: $id. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of global Twitch emotes to their URL. - static Future?> getEmotesTwitchGlobal() async { - final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global'); - final response = await http.get(url, headers: AuthenticationProvider.authHeaders); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final List emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); - - final emoteToUrl = {}; - for (final emote in emotes) { - emoteToUrl[emote.name] = 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0'; - } - - return emoteToUrl; - } else { - debugPrint('Failed to get global Twitch emotes. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of a channel's Twitch emotes to their URL. - static Future?> getEmotesTwitchChannel({required String id}) async { - final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes?broadcaster_id=$id'); - final response = await http.get(url, headers: AuthenticationProvider.authHeaders); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final List emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); - - final emoteToUrl = {}; - for (final emote in emotes) { - emoteToUrl[emote.name] = 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0'; - } - - return emoteToUrl; - } else { - debugPrint('Failed to get Twitch emotes for id: $id. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of global Twitch badges to their URL. - static Future?> getBadgesTwitchGlobal() async { - final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global'); - final response = await http.get(url, headers: AuthenticationProvider.authHeaders); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final List badges = decoded.map((emote) => BadgesTwitch.fromJson(emote)).toList(); - - final badgeToUrl = {}; - for (final badge in badges) { - for (final badgeVersion in badge.versions) { - badgeToUrl['${badge.setId}/${badgeVersion.id}'] = badgeVersion.imageUrl4x; - } - } - - return badgeToUrl; - } else { - debugPrint('Failed to get global Twitch badges. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of a channel's Twitch badges to their URL. - static Future?> getBadgesTwitchChannel({required String id}) async { - final url = Uri.parse('https://api.twitch.tv/helix/chat/badges?broadcaster_id=$id'); - final response = await http.get(url, headers: AuthenticationProvider.authHeaders); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body)['data'] as List; - final List badges = decoded.map((emote) => BadgesTwitch.fromJson(emote)).toList(); - - final badgeToUrl = {}; - for (final badge in badges) { - for (final badgeVersion in badge.versions) { - badgeToUrl['${badge.setId}/${badgeVersion.id}'] = badgeVersion.imageUrl4x; - } - } - - return badgeToUrl; - } else { - debugPrint('Failed to get Twitch badges for id: $id. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of global 7TV emotes to their URL. - static Future?> getEmotes7TVGlobal() async { - final url = Uri.parse('https://api.7tv.app/v2/emotes/global'); - final response = await http.get(url); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final List emotes = decoded.map((emote) => Emote7TV.fromJson(emote)).toList(); - - final emoteToUrl = {}; - for (final emote in emotes) { - emoteToUrl[emote.name] = emote.urls[3][1]; - } - return emoteToUrl; - } else { - debugPrint('Failed to get global 7TV emotes. Error code: ${response.statusCode}'); - } - } - - /// Returns a map of a channel's 7TV emotes to their URL. - static Future?> getEmotes7TVChannel({required String user}) async { - final url = Uri.parse('https://api.7tv.app/v2/users/$user/emotes'); - final response = await http.get(url); - - if (response.statusCode == 200) { - final decoded = jsonDecode(response.body) as List; - final List emotes = decoded.map((emote) => Emote7TV.fromJson(emote)).toList(); - - final emoteToUrl = {}; - for (final emote in emotes) { - emoteToUrl[emote.name] = emote.urls[3][1]; - } - return emoteToUrl; - } else { - debugPrint('Failed to get channel 7TV emotes. Error code: ${response.statusCode}'); - } - } -} diff --git a/lib/widgets/channel_card.dart b/lib/widgets/channel_card.dart index 3eb722d2..025eed29 100644 --- a/lib/widgets/channel_card.dart +++ b/lib/widgets/channel_card.dart @@ -2,9 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:frosty/models/channel.dart'; -import 'package:frosty/providers/chat_provider.dart'; import 'package:frosty/screens/video_chat.dart'; -import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; /// A card widget that displays a live channel's thumbnail and details. @@ -22,12 +20,7 @@ class ChannelCard extends StatelessWidget { context, CupertinoPageRoute( builder: (context) { - return Scaffold( - body: ChangeNotifierProvider( - create: (context) => ChatProvider(channelInfo: channelInfo), - child: VideoChat(channelInfo: channelInfo), - ), - ); + return VideoChat(channelInfo: channelInfo); }, ), ); diff --git a/lib/widgets/chat.dart b/lib/widgets/chat.dart index 582497c0..65cc7562 100644 --- a/lib/widgets/chat.dart +++ b/lib/widgets/chat.dart @@ -1,25 +1,32 @@ import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:frosty/models/channel.dart'; -import 'package:frosty/providers/chat_provider.dart'; -import 'package:provider/provider.dart'; +import 'package:frosty/stores/auth_store.dart'; +import 'package:frosty/stores/chat_store.dart'; +import 'package:get_it/get_it.dart'; -class Chat extends StatelessWidget { +class Chat extends StatefulWidget { final Channel channelInfo; const Chat({Key? key, required this.channelInfo}) : super(key: key); + @override + _ChatState createState() => _ChatState(); +} + +class _ChatState extends State { + late final ChatStore chatStore = ChatStore(auth: GetIt.I(), channelInfo: widget.channelInfo); + @override Widget build(BuildContext context) { - debugPrint('build'); - final viewModel = context.read(); return FutureBuilder( - future: viewModel.getEmotes(), - builder: (context, snapshot) { + future: chatStore.getAssets(), + builder: (_, snapshot) { if (snapshot.connectionState == ConnectionState.done) { return StreamBuilder( - stream: viewModel.channel.stream, + stream: chatStore.channel.stream, builder: (context, snapshot) { - viewModel.handleWebsocketData(snapshot.data); + chatStore.handleWebsocketData(snapshot.data); return Stack( alignment: AlignmentDirectional.bottomCenter, children: [ @@ -27,22 +34,22 @@ class Chat extends StatelessWidget { addAutomaticKeepAlives: false, addRepaintBoundaries: false, physics: const ClampingScrollPhysics(), - itemCount: viewModel.messages.length, - controller: viewModel.scrollController, + itemCount: chatStore.messages.length, + controller: chatStore.scrollController, padding: const EdgeInsets.all(5.0), itemBuilder: (context, index) { - return viewModel.parseIrcMessage(viewModel.messages[index]); + return chatStore.parseIrcMessage(chatStore.messages[index]); }, ), - Consumer( - builder: (context, viewModel, child) { + Observer( + builder: (_) { return Visibility( - visible: !viewModel.autoScroll, + visible: !chatStore.autoScroll, child: Container( padding: const EdgeInsets.symmetric(horizontal: 4.0), width: double.infinity, child: ElevatedButton( - onPressed: () => viewModel.resumeScroll(), + onPressed: () => chatStore.resumeScroll(), child: const Text('Resume Scroll'), ), ), @@ -60,4 +67,10 @@ class Chat extends StatelessWidget { }, ); } + + @override + void dispose() { + chatStore.channel.sink.close(); + super.dispose(); + } } diff --git a/pubspec.lock b/pubspec.lock index dba3ea34..80cfc3e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,14 +7,14 @@ packages: name: _fe_analyzer_shared url: "https://pub.dartlang.org" source: hosted - version: "26.0.0" + version: "27.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.dartlang.org" source: hosted - version: "2.3.0" + version: "2.4.0" args: dependency: transitive description: @@ -42,7 +42,7 @@ packages: name: build url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" build_config: dependency: transitive description: @@ -56,7 +56,7 @@ packages: name: build_daemon url: "https://pub.dartlang.org" source: hosted - version: "3.0.0" + version: "3.0.1" build_resolvers: dependency: transitive description: @@ -70,14 +70,14 @@ packages: name: build_runner url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.4" build_runner_core: dependency: transitive description: name: build_runner_core url: "https://pub.dartlang.org" source: hosted - version: "7.1.0" + version: "7.2.2" built_collection: dependency: transitive description: @@ -140,7 +140,7 @@ packages: name: cli_util url: "https://pub.dartlang.org" source: hosted - version: "0.3.3" + version: "0.3.4" clock: dependency: transitive description: @@ -244,6 +244,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" + flutter_mobx: + dependency: "direct main" + description: + name: flutter_mobx + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" flutter_secure_storage: dependency: "direct main" description: @@ -310,13 +317,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + get_it: + dependency: "direct main" + description: + name: get_it + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.0" glob: dependency: transitive description: name: glob url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" graphs: dependency: transitive description: @@ -415,13 +429,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.0" - nested: - dependency: transitive + mobx: + dependency: "direct main" description: - name: nested + name: mobx url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "2.0.4" + mobx_codegen: + dependency: "direct dev" + description: + name: mobx_codegen + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" octo_image: dependency: transitive description: @@ -498,7 +519,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.2" pool: dependency: transitive description: @@ -513,13 +534,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.3" - provider: - dependency: "direct main" - description: - name: provider - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.0" pub_semver: dependency: transitive description: @@ -566,14 +580,14 @@ packages: name: source_gen url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.1.1" source_helper: dependency: transitive description: name: source_helper url: "https://pub.dartlang.org" source: hosted - version: "1.2.1" + version: "1.3.0" source_span: dependency: transitive description: @@ -678,7 +692,7 @@ packages: name: watcher url: "https://pub.dartlang.org" source: hosted - version: "1.0.0" + version: "1.0.1" web_socket_channel: dependency: transitive description: @@ -692,6 +706,27 @@ packages: name: webview_flutter url: "https://pub.dartlang.org" source: hosted + version: "2.1.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.15" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + url: "https://pub.dartlang.org" + source: hosted version: "2.0.14" win32: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 1d824fcb..49509f0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,12 +36,14 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 http: ^0.13.3 - provider: ^6.0.0 flutter_secure_storage: ^5.0.0-beta.5 flutter_web_auth: ^0.3.1 intl: ^0.17.0 cached_network_image: ^3.1.0 webview_flutter: ^2.0.13 + mobx: ^2.0.0 + flutter_mobx: ^2.0.0 + get_it: ^7.2.0 dev_dependencies: flutter_test: @@ -55,6 +57,7 @@ dev_dependencies: flutter_lints: ^1.0.0 build_runner: ^2.1.1 json_serializable: ^5.0.1 + mobx_codegen: ^2.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec