Merge pull request #23 from tommyxchow/tommyxchow/issue22
Migrate state management to MobX and GetIt + remove Provider dependency
This commit is contained in:
commit
4df6383652
24 changed files with 1026 additions and 693 deletions
49
lib/api/bttv_api.dart
Normal file
49
lib/api/bttv_api.dart
Normal 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
46
lib/api/ffz_api.dart
Normal 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
45
lib/api/seventv_api.dart
Normal 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
179
lib/api/twitch_api.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
class SettingsProvider extends ChangeNotifier {}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
124
lib/stores/auth_store.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
107
lib/stores/auth_store.g.dart
Normal file
107
lib/stores/auth_store.g.dart
Normal 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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
100
lib/stores/channel_list_store.dart
Normal file
100
lib/stores/channel_list_store.dart
Normal 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 }
|
||||
65
lib/stores/channel_list_store.g.dart
Normal file
65
lib/stores/channel_list_store.g.dart
Normal 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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
65
lib/stores/chat_store.g.dart
Normal file
65
lib/stores/chat_store.g.dart
Normal 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}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
|
@ -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}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
pubspec.lock
81
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue