* Use adaptive refresh indicators
* Add new message count indicator in chat
* Sync pubspec.lock
* Initial migration to material 3
* Restore default bottom sheet style
* Remove FrostyListTile and reset styles
* Use "Clear" button for all search history
* Add `require_trailing_commas` lint rule
* Exclude generated files from linter
* Use ListView for safe area support in bottom sheet
* Add initial dark theme
* Make sizing and units consistent
* Update onbaording spacings & copy
* Migrate to default material 3 buttons
* Add headers to chat details bottom sheet
* Simplify token log in method
* Use bottom sheet for account options
* Remove `AnimateScale` widget
* Tweak search paddings
* Update message spacing divisions
* Upgrade packages
* Update message spacing divisions
* Regenerate splash screens
* Update Inter & add google fonts
* Add initial material 3 themes
* Use adaptive alert dialogs
* Revert to oultined inputs
* Fix tooltip message
* Revert add google_fonts
* Improve themes and various styling
* Convert categories grid to list
* Tweak stream card thumbnail radii
* Tweak settings headers
* Left align floating snackbars
* Tweak search header padding
* Fix profile pics occasionally not rounded
* Use profile pic for settings button if logged in
* Redesign sleep timer
* Use divider theme and reduce thickness
* Use 0 letter spacing for tab bar titles
* Redesign category streams list
* Add border to tooltips
* Increase category card font weight
* Use outlined icon btton for chat user modal
* Reduce profile pic radius in search results
* Fix scrolll to top button safe area
* Add support for reply threads
* Reduce tooltip and snackbar border thickness
* Rebuild reply threads on new messages
* Upgrade packages (iOS)
* Reduce font weight for all chat alerts
* Use tabbars for pageview
* Add dividers everywhere
* Revert "Add dividers everywhere"
This reverts commit 5f0d349fdb.
* Remove fill from inputs
* Add dividers to settings
* Use segmented button for settings list
* Add scrollbar to lists with scrollcontroller
* Redesign badge/emote tooltip
* Remove outline from chat user modal buttons
* Remove divider from reply thread
* Use tap for chatters list and increase font weight
* Constrain emote tooltip width
525 lines
17 KiB
Dart
525 lines
17 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:frosty/constants.dart';
|
|
import 'package:frosty/models/badges.dart';
|
|
import 'package:frosty/models/category.dart';
|
|
import 'package:frosty/models/channel.dart';
|
|
import 'package:frosty/models/emotes.dart';
|
|
import 'package:frosty/models/stream.dart';
|
|
import 'package:frosty/models/user.dart';
|
|
import 'package:http/http.dart';
|
|
|
|
/// The Twitch service for making API calls.
|
|
class TwitchApi {
|
|
final Client _client;
|
|
|
|
const TwitchApi(this._client);
|
|
|
|
/// Returns a list of all Twitch global emotes.
|
|
Future<List<Emote>> getEmotesGlobal({
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global');
|
|
final response = await _client.get(url, headers: headers);
|
|
|
|
if (response.statusCode == 200) {
|
|
final decoded = jsonDecode(response.body)['data'] as List;
|
|
final emotes =
|
|
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
|
|
|
|
return emotes
|
|
.map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal))
|
|
.toList();
|
|
} else {
|
|
return Future.error('Failed to get Twitch global emotes');
|
|
}
|
|
}
|
|
|
|
/// Returns a list of a channel's Twitch emotes given their [id].
|
|
Future<List<Emote>> 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 _client.get(url, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final decoded = jsonDecode(response.body)['data'] as List;
|
|
final emotes =
|
|
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
|
|
|
|
return emotes.map((emote) {
|
|
switch (emote.emoteType) {
|
|
case 'bitstier':
|
|
return Emote.fromTwitch(emote, EmoteType.twitchBits);
|
|
case 'follower':
|
|
return Emote.fromTwitch(emote, EmoteType.twitchFollower);
|
|
case 'subscriptions':
|
|
return Emote.fromTwitch(emote, EmoteType.twitchChannel);
|
|
default:
|
|
return Emote.fromTwitch(emote, EmoteType.twitchChannel);
|
|
}
|
|
}).toList();
|
|
} else {
|
|
return Future.error('Failed to get Twitch channel emotes');
|
|
}
|
|
}
|
|
|
|
/// Returns a list of Twitch emotes under the provided [setId].
|
|
Future<List<Emote>> getEmotesSets({
|
|
required String setId,
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final decoded = jsonDecode(response.body)['data'] as List;
|
|
final emotes =
|
|
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
|
|
|
|
return emotes.map((emote) {
|
|
switch (emote.emoteType) {
|
|
case 'globals':
|
|
case 'smilies':
|
|
return Emote.fromTwitch(emote, EmoteType.twitchGlobal);
|
|
case 'subscriptions':
|
|
return Emote.fromTwitch(emote, EmoteType.twitchSub);
|
|
default:
|
|
return Emote.fromTwitch(emote, EmoteType.twitchUnlocked);
|
|
}
|
|
}).toList();
|
|
} else {
|
|
return Future.error('Failed to get Twitch emotes set');
|
|
}
|
|
}
|
|
|
|
/// Returns a map of global Twitch badges to their [Emote] object.
|
|
Future<Map<String, ChatBadge>> getBadgesGlobal({
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global');
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final result = <String, ChatBadge>{};
|
|
final decoded = jsonDecode(response.body)['data'] as List;
|
|
|
|
for (final badge in decoded) {
|
|
final id = badge['set_id'] as String;
|
|
final versions = badge['versions'] as List;
|
|
|
|
for (final version in versions) {
|
|
final badgeInfo = BadgeInfoTwitch.fromJson(version);
|
|
result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} else {
|
|
return Future.error('Failed to get Twitch global badges');
|
|
}
|
|
}
|
|
|
|
/// Returns a map of a channel's Twitch badges to their [Emote] object.
|
|
Future<Map<String, ChatBadge>> 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 _client.get(url, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final result = <String, ChatBadge>{};
|
|
final decoded = jsonDecode(response.body)['data'] as List;
|
|
|
|
for (final badge in decoded) {
|
|
final id = badge['set_id'] as String;
|
|
final versions = badge['versions'] as List;
|
|
|
|
for (final version in versions) {
|
|
final badgeInfo = BadgeInfoTwitch.fromJson(version);
|
|
result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} else {
|
|
return Future.error('Failed to get Twitch channel badges');
|
|
}
|
|
}
|
|
|
|
/// Returns the user's info given their token through [headers].
|
|
Future<UserTwitch> getUserInfo({required Map<String, String> headers}) async {
|
|
final url = Uri.parse('https://api.twitch.tv/helix/users');
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final userData = jsonDecode(response.body)['data'] as List;
|
|
|
|
return UserTwitch.fromJson(userData.first);
|
|
} else {
|
|
return Future.error('Failed to get Twitch user info');
|
|
}
|
|
}
|
|
|
|
/// Returns a token for an anonymous user.
|
|
Future<String> getDefaultToken() async {
|
|
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 _client.post(url);
|
|
if (response.statusCode == 200) {
|
|
return jsonDecode(response.body)['access_token'];
|
|
} else {
|
|
return Future.error('Failed to get default token');
|
|
}
|
|
}
|
|
|
|
/// Returns a bool indicating the validity of the given token.
|
|
Future<bool> validateToken({required String token}) async {
|
|
final url = Uri.parse('https://id.twitch.tv/oauth2/validate');
|
|
|
|
final response =
|
|
await _client.get(url, headers: {'Authorization': 'Bearer $token'});
|
|
if (response.statusCode == 200) {
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests.
|
|
Future<StreamsTwitch> getTopStreams({
|
|
required Map<String, String> headers,
|
|
String? cursor,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
cursor == null
|
|
? 'https://api.twitch.tv/helix/streams'
|
|
: 'https://api.twitch.tv/helix/streams?after=$cursor',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
final decoded = jsonDecode(response.body);
|
|
if (response.statusCode == 200) {
|
|
return StreamsTwitch.fromJson(decoded);
|
|
} else {
|
|
return Future.error('Failed to get top streams: ${decoded['message']}');
|
|
}
|
|
}
|
|
|
|
/// Returns a [StreamsTwitch] object that contains the given user ID's top 20 followed streams and a cursor for further requests.
|
|
Future<StreamsTwitch> getFollowedStreams({
|
|
required String id,
|
|
required Map<String, String> headers,
|
|
String? cursor,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
cursor == null
|
|
? 'https://api.twitch.tv/helix/streams/followed?user_id=$id'
|
|
: 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
final decoded = jsonDecode(response.body);
|
|
if (response.statusCode == 200) {
|
|
return StreamsTwitch.fromJson(decoded);
|
|
} else {
|
|
return Future.error(
|
|
'Failed to get followed streams: ${decoded['message']}',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
|
|
Future<StreamsTwitch> getStreamsUnderCategory({
|
|
required String gameId,
|
|
required Map<String, String> headers,
|
|
String? cursor,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
cursor == null
|
|
? 'https://api.twitch.tv/helix/streams?game_id=$gameId'
|
|
: 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
final decoded = jsonDecode(response.body);
|
|
if (response.statusCode == 200) {
|
|
return StreamsTwitch.fromJson(decoded);
|
|
} else {
|
|
return Future.error(
|
|
'Failed to get streams under category: ${decoded['message']}',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
|
Future<StreamTwitch> getStream({
|
|
required String userLogin,
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final uri =
|
|
Uri.parse('https://api.twitch.tv/helix/streams?user_login=$userLogin');
|
|
|
|
final response = await _client.get(uri, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final decoded = jsonDecode(response.body);
|
|
final data = decoded['data'] as List;
|
|
|
|
if (data.isNotEmpty) {
|
|
return StreamTwitch.fromJson(data.first);
|
|
} else {
|
|
return Future.error('$userLogin is offline');
|
|
}
|
|
} else {
|
|
return Future.error('Failed to get stream info');
|
|
}
|
|
}
|
|
|
|
/// Returns a [UserTwitch] object containing the user info associated with the given [userLogin].
|
|
Future<UserTwitch> getUser({
|
|
String? userLogin,
|
|
String? id,
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
id != null
|
|
? 'https://api.twitch.tv/helix/users?id=$id'
|
|
: 'https://api.twitch.tv/helix/users?login=$userLogin',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
final decoded = jsonDecode(response.body);
|
|
|
|
if (response.statusCode == 200) {
|
|
final userData = decoded['data'] as List;
|
|
|
|
if (userData.isNotEmpty) {
|
|
return UserTwitch.fromJson(userData.first);
|
|
} else {
|
|
return Future.error('User does not exist');
|
|
}
|
|
} else {
|
|
return Future.error('Failed to get user: ${decoded['message']}');
|
|
}
|
|
}
|
|
|
|
/// Returns a [Channel] object containing a channels's info associated with the given [userId].
|
|
Future<Channel> getChannel({
|
|
required String userId,
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
'https://api.twitch.tv/helix/channels?broadcaster_id=$userId',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
final decoded = jsonDecode(response.body);
|
|
if (response.statusCode == 200) {
|
|
final channelData = decoded['data'] as List;
|
|
|
|
if (channelData.isNotEmpty) {
|
|
return Channel.fromJson(channelData.first);
|
|
} else {
|
|
return Future.error('Channel does not exist');
|
|
}
|
|
} else {
|
|
return Future.error('Failed to get channel: ${decoded['message']}');
|
|
}
|
|
}
|
|
|
|
/// Returns a list of [ChannelQuery] objects closest matching the given [query].
|
|
Future<List<ChannelQuery>> searchChannels({
|
|
required String query,
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
'https://api.twitch.tv/helix/search/channels?first=8&query=$query',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final channelData = jsonDecode(response.body)['data'] as List;
|
|
|
|
return channelData.map((e) => ChannelQuery.fromJson(e)).toList();
|
|
} else {
|
|
return Future.error('Failed to get channels');
|
|
}
|
|
}
|
|
|
|
/// Returns a [CategoriesTwitch] object containing the next top 20 categories/games and a cursor for further requests.
|
|
Future<CategoriesTwitch> getTopCategories({
|
|
required Map<String, String> headers,
|
|
String? cursor,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
cursor == null
|
|
? 'https://api.twitch.tv/helix/games/top'
|
|
: 'https://api.twitch.tv/helix/games/top?after=$cursor',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
final decoded = jsonDecode(response.body);
|
|
if (response.statusCode == 200) {
|
|
return CategoriesTwitch.fromJson(decoded);
|
|
} else {
|
|
return Future.error(
|
|
'Failed to get top categories: ${decoded['message']}',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Returns a [CategoriesTwitch] object containing the category info corresponding to the provided [gameId].
|
|
Future<CategoriesTwitch> getCategory({
|
|
required Map<String, String> headers,
|
|
required String gameId,
|
|
}) async {
|
|
final url = Uri.parse('https://api.twitch.tv/helix/games?id=$gameId');
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
final decoded = jsonDecode(response.body);
|
|
if (response.statusCode == 200) {
|
|
return CategoriesTwitch.fromJson(decoded);
|
|
} else {
|
|
return Future.error('Failed to get category: ${decoded['message']}');
|
|
}
|
|
}
|
|
|
|
/// Returns a [CategoriesTwitch] containing up to 20 categories/games closest matching the [query] and a cursor for further requests.
|
|
Future<CategoriesTwitch> searchCategories({
|
|
required Map<String, String> headers,
|
|
required String query,
|
|
String? cursor,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
cursor == null
|
|
? 'https://api.twitch.tv/helix/search/categories?first=8&query=$query'
|
|
: 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final decoded = jsonDecode(response.body);
|
|
|
|
return CategoriesTwitch.fromJson(decoded);
|
|
} else {
|
|
return Future.error('Failed to get categories');
|
|
}
|
|
}
|
|
|
|
/// Returns the sub count associated with the given [userId].
|
|
Future<int> getSubscriberCount({
|
|
required String userId,
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final uri = Uri.parse(
|
|
'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId',
|
|
);
|
|
|
|
final response = await _client.get(uri, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final decoded = jsonDecode(response.body);
|
|
|
|
return decoded['total'] as int;
|
|
} else {
|
|
return Future.error('Failed to get sub count');
|
|
}
|
|
}
|
|
|
|
/// Returns a user's list of blocked users given their id.
|
|
Future<List<UserBlockedTwitch>> getUserBlockedList({
|
|
required String id,
|
|
required Map<String, String> headers,
|
|
String? cursor,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
cursor == null
|
|
? 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id'
|
|
: 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor',
|
|
);
|
|
|
|
final response = await _client.get(url, headers: headers);
|
|
if (response.statusCode == 200) {
|
|
final decoded = jsonDecode(response.body);
|
|
final cursor = decoded['pagination']['cursor'];
|
|
final blockedList = decoded['data'] as List;
|
|
|
|
if (blockedList.isNotEmpty) {
|
|
final result =
|
|
blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList();
|
|
|
|
if (cursor != null) {
|
|
// Wait a bit (150 milliseconds) before recursively calling.
|
|
// This will prevent going over the rate limit to due a massive blocked users list.
|
|
//
|
|
// With the Twitch API, we can make up to 800 requests per minute.
|
|
// Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute.
|
|
await Future.delayed(const Duration(milliseconds: 150));
|
|
result.addAll(
|
|
await getUserBlockedList(
|
|
id: id,
|
|
cursor: cursor,
|
|
headers: headers,
|
|
),
|
|
);
|
|
}
|
|
|
|
return result;
|
|
} else {
|
|
debugPrint('User does not have anyone blocked');
|
|
return [];
|
|
}
|
|
} else {
|
|
return Future.error('User does not exist');
|
|
}
|
|
}
|
|
|
|
// Blocks the user with the given ID and returns true on success or false on failure.
|
|
Future<bool> blockUser({
|
|
required String userId,
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
|
|
);
|
|
|
|
final response = await _client.put(url, headers: headers);
|
|
if (response.statusCode == 204) {
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Unblocks the user with the given ID and returns true on success or false on failure.
|
|
Future<bool> unblockUser({
|
|
required String userId,
|
|
required Map<String, String> headers,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
|
|
);
|
|
|
|
final response = await _client.delete(url, headers: headers);
|
|
if (response.statusCode == 204) {
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|