Merge pull request #23 from tommyxchow/tommyxchow/issue22

Migrate state management to MobX and GetIt + remove Provider dependency
This commit is contained in:
Tommy Chow 2021-10-04 01:43:17 -04:00 • committed by GitHub
commit 4df6383652
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1026 additions and 693 deletions

49
lib/api/bttv_api.dart Normal file
View file

@ -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<Map<String, String>?> 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<EmoteBTTVGlobal> emotes = decoded.map((emote) => EmoteBTTVGlobal.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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 = <String, String>{};
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}');
}
}
}

46
lib/api/ffz_api.dart Normal file
View file

@ -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<Map<String, String>?> 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<EmoteFFZ> emotes = decoded.map((emote) => EmoteFFZ.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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<EmoteFFZ> emotes = decoded.map((emote) => EmoteFFZ.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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}');
}
}
}

45
lib/api/seventv_api.dart Normal file
View file

@ -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<Map<String, String>?> 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<Emote7TV> emotes = decoded.map((emote) => Emote7TV.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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<Emote7TV> emotes = decoded.map((emote) => Emote7TV.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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}');
}
}
}

179
lib/api/twitch_api.dart Normal file
View file

@ -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<Map<String, String>?> getEmotesGlobal({required Map<String, String>? 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<EmoteTwitch> emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> getEmotesChannel({required String id, required Map<String, String>? 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<EmoteTwitch> emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> getBadgesGlobal({required Map<String, String>? 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<BadgesTwitch> badges = decoded.map((emote) => BadgesTwitch.fromJson(emote)).toList();
final badgeToUrl = <String, String>{};
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<Map<String, String>?> getBadgesChannel({required String id, required Map<String, String>? 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<BadgesTwitch> badges = decoded.map((emote) => BadgesTwitch.fromJson(emote)).toList();
final badgeToUrl = <String, String>{};
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<UserTwitch> getUserInfo({required Map<String, String>? 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<String> 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<bool> 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<Map<String, dynamic>?> getTopChannels({required Map<String, String>? 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<Map<String, dynamic>?> getFollowedChannels({required String id, required Map<String, String>? 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');
}
}
}

View file

@ -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>(AuthStore());
runApp(const MyApp());
}
@ -14,29 +14,23 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthenticationProvider>(create: (_) => AuthenticationProvider()),
ChangeNotifierProxyProvider<AuthenticationProvider, SettingsProvider>(
create: (_) => SettingsProvider(),
update: (context, auth, settingsProvider) {
return SettingsProvider();
final auth = GetIt.I<AuthStore>();
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<AuthenticationProvider, ChannelListProvider>(
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(),
),
);
}

View file

@ -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 {

View file

@ -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<String, String>? 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<void> 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<void> _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<void> _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<void> _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<void> 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();
}
}

View file

@ -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 = <Channel>[];
var followedChannels = <Channel>[];
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<Channel> channels({required Category category}) {
switch (category) {
case Category.top:
return topChannels;
case Category.followed:
return followedChannels;
}
}
Future<void> 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<void> 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<void> 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<void> 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 }

View file

@ -1,3 +0,0 @@
import 'package:flutter/cupertino.dart';
class SettingsProvider extends ChangeNotifier {}

View file

@ -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<ChannelList> with AutomaticKeepAliveClientMixin<ChannelList> {
@override
Widget build(BuildContext context) {
debugPrint('build channel list');
super.build(context);
return Consumer2<AuthenticationProvider, ChannelListProvider>(
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),
);
}

View file

@ -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<AuthenticationProvider>();
final auth = GetIt.I<AuthStore>();
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'),
),
],
),
),
);
},
);
}

View file

@ -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<SettingsProvider, AuthenticationProvider>(
builder: (context, settings, auth, child) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
),
body: Center(
debugPrint('settings');
final auth = GetIt.I<AuthStore>();
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(),
),
),
);
},
);
},
),
);
}
}

View file

@ -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),
)
],
),
),
);
}

124
lib/stores/auth_store.dart Normal file
View file

@ -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<String, String> get headersTwitch => {'Authorization': 'Bearer $_token', 'Client-Id': clientId};
/// Initialize by retrieving a token if it does not already exist.
@action
Future<void> 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<void> 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<void> 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');
}
}

View file

@ -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<Map<String, String>>? _$headersTwitchComputed;
@override
Map<String, String> get headersTwitch => (_$headersTwitchComputed ??=
Computed<Map<String, String>>(() => 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<void> init() {
return _$initAsyncAction.run(() => super.init());
}
final _$loginAsyncAction = AsyncAction('_AuthBase.login');
@override
Future<void> login() {
return _$loginAsyncAction.run(() => super.login());
}
final _$logoutAsyncAction = AsyncAction('_AuthBase.logout');
@override
Future<void> logout() {
return _$logoutAsyncAction.run(() => super.logout());
}
@override
String toString() {
return '''
headersTwitch: ${headersTwitch}
''';
}
}

View file

@ -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<Channel> topChannels = ObservableList<Channel>();
@observable
ObservableList<Channel> followedChannels = ObservableList<Channel>();
bool isLoading = false;
String? topChannelsCurrentCursor;
String? followedChannelsCurrentCursor;
ObservableList<Channel> 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<void> 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<void> 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 }

View file

@ -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<Channel> get topChannels {
_$topChannelsAtom.reportRead();
return super.topChannels;
}
@override
set topChannels(ObservableList<Channel> value) {
_$topChannelsAtom.reportWrite(value, super.topChannels, () {
super.topChannels = value;
});
}
final _$followedChannelsAtom =
Atom(name: '_ChannelListBase.followedChannels');
@override
ObservableList<Channel> get followedChannels {
_$followedChannelsAtom.reportRead();
return super.followedChannels;
}
@override
set followedChannels(ObservableList<Channel> value) {
_$followedChannelsAtom.reportWrite(value, super.followedChannels, () {
super.followedChannels = value;
});
}
final _$refreshAsyncAction = AsyncAction('_ChannelListBase.refresh');
@override
Future<void> refresh({required ChannelCategory category}) {
return _$refreshAsyncAction.run(() => super.refresh(category: category));
}
final _$getChannelsAsyncAction = AsyncAction('_ChannelListBase.getChannels');
@override
Future<void> getChannels({required ChannelCategory category}) {
return _$getChannelsAsyncAction
.run(() => super.getChannels(category: category));
}
@override
String toString() {
return '''
topChannels: ${topChannels},
followedChannels: ${followedChannels}
''';
}
}

View file

@ -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<String> messages = [];
final Channel channelInfo;
final channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
final messages = <String>[];
final scrollController = ScrollController();
final _assetToUrl = <String, String>{};
final _emoteIdToWord = <String, String>{};
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<void> getEmotes() async {
@action
Future<void> 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

View file

@ -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<void> 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}
''';
}
}

View file

@ -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<Map<String, String>?> 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<EmoteBTTVGlobal> emotes = decoded.map((emote) => EmoteBTTVGlobal.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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 = <String, String>{};
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<Map<String, String>?> 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<EmoteFFZ> emotes = decoded.map((emote) => EmoteFFZ.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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<EmoteFFZ> emotes = decoded.map((emote) => EmoteFFZ.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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<EmoteTwitch> emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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<EmoteTwitch> emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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<BadgesTwitch> badges = decoded.map((emote) => BadgesTwitch.fromJson(emote)).toList();
final badgeToUrl = <String, String>{};
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<Map<String, String>?> 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<BadgesTwitch> badges = decoded.map((emote) => BadgesTwitch.fromJson(emote)).toList();
final badgeToUrl = <String, String>{};
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<Map<String, String>?> 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<Emote7TV> emotes = decoded.map((emote) => Emote7TV.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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<Map<String, String>?> 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<Emote7TV> emotes = decoded.map((emote) => Emote7TV.fromJson(emote)).toList();
final emoteToUrl = <String, String>{};
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}');
}
}
}

View file

@ -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<ChatProvider>(
create: (context) => ChatProvider(channelInfo: channelInfo),
child: VideoChat(channelInfo: channelInfo),
),
);
return VideoChat(channelInfo: channelInfo);
},
),
);

View file

@ -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<Chat> {
late final ChatStore chatStore = ChatStore(auth: GetIt.I<AuthStore>(), channelInfo: widget.channelInfo);
@override
Widget build(BuildContext context) {
debugPrint('build');
final viewModel = context.read<ChatProvider>();
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<ChatProvider>(
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();
}
}

View file

@ -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

View file

@ -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