Update linting and formatting rules (#261)

* Add new linting rules

* Apply new linting rules

* Fix dart sdk version

* Format code to 80 lines
This commit is contained in:
Tommy 2023-04-29 11:34:28 -04:00 • committed by GitHub
parent f17cef9523
commit 3af5406f60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 1049 additions and 481 deletions

View file

@ -27,6 +27,13 @@ linter:
rules:
- directives_ordering
- prefer_single_quotes
- always_use_package_imports
- avoid_void_async
- prefer_final_locals
- prefer_final_in_for_each
- avoid_redundant_argument_values
- unnecessary_parenthesis
- always_declare_return_types
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View file

@ -19,7 +19,9 @@ class BTTVApi {
final decoded = jsonDecode(response.body) as List;
final emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList();
return emotes.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal)).toList();
return emotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvGlobal))
.toList();
} else {
return Future.error('Failed to get BTTV global emotes');
}
@ -27,7 +29,8 @@ class BTTVApi {
/// Returns a map of a channel's BTTV emotes to their URL.
Future<List<Emote>> getEmotesChannel({required String id}) async {
final url = Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id');
final url =
Uri.parse('https://api.betterttv.net/3/cached/users/twitch/$id');
final response = await _client.get(url);
if (response.statusCode == 200) {
@ -35,8 +38,10 @@ class BTTVApi {
final result = EmoteBTTVChannel.fromJson(decoded);
final emoteToUrl = <Emote>[];
emoteToUrl.addAll(result.channelEmotes.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)));
emoteToUrl.addAll(result.sharedEmotes.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)));
emoteToUrl.addAll(result.channelEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)));
emoteToUrl.addAll(result.sharedEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)));
return emoteToUrl;
} else {
@ -52,9 +57,13 @@ class BTTVApi {
if (response.statusCode == 200) {
final badges = jsonDecode(response.body) as List;
final badgeObjects = badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList();
final badgeObjects =
badges.map((badge) => BadgeInfoBTTV.fromJson(badge)).toList();
return {for (final badge in badgeObjects) badge.providerId: ChatBadge.fromBTTV(badge)};
return {
for (final badge in badgeObjects)
badge.providerId: ChatBadge.fromBTTV(badge)
};
} else {
return Future.error('Failed to get BTTV badges');
}

View file

@ -22,11 +22,14 @@ class FFZApi {
final emotes = <EmoteFFZ>[];
for (final setId in defaultSets) {
final emoticons = decoded['sets'][setId.toString()]['emoticons'] as List;
final emoticons =
decoded['sets'][setId.toString()]['emoticons'] as List;
emotes.addAll(emoticons.map((emote) => EmoteFFZ.fromJson(emote)));
}
return emotes.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal)).toList();
return emotes
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzGlobal))
.toList();
} else {
return Future.error('Failed to get FFZ global emotes');
}
@ -40,11 +43,16 @@ class FFZApi {
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final roomInfo = RoomFFZ.fromJson(decoded['room']);
final emoticons = decoded['sets'][roomInfo.set.toString()]['emoticons'] as List;
final emoticons =
decoded['sets'][roomInfo.set.toString()]['emoticons'] as List;
final emotes = emoticons.map((emote) => EmoteFFZ.fromJson(emote));
return Tuple2(roomInfo, emotes.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel)).toList());
return Tuple2(
roomInfo,
emotes
.map((emote) => Emote.fromFFZ(emote, EmoteType.ffzChannel))
.toList());
} else {
return Future.error('Failed to get FFZ room info');
}
@ -58,7 +66,8 @@ class FFZApi {
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final badges = decoded['badges'] as List;
final badgeObjects = badges.map((badge) => BadgeInfoFFZ.fromJson(badge)).toList();
final badgeObjects =
badges.map((badge) => BadgeInfoFFZ.fromJson(badge)).toList();
final result = <String, List<ChatBadge>>{};
for (final badge in badgeObjects.reversed) {

View file

@ -19,7 +19,9 @@ class SevenTVApi {
final decoded = jsonDecode(response.body) as List;
final emotes = decoded.map((emote) => Emote7TV.fromJson(emote));
return emotes.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal)).toList();
return emotes
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVGlobal))
.toList();
} else {
return Future.error('Failed to get 7TV global emotes');
}
@ -34,7 +36,9 @@ class SevenTVApi {
final decoded = jsonDecode(response.body) as List;
final emotes = decoded.map((emote) => Emote7TV.fromJson(emote));
return emotes.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel)).toList();
return emotes
.map((emote) => Emote.from7TV(emote, EmoteType.sevenTVChannel))
.toList();
} else {
return Future.error('Failed to get 7TV channel emotes');
}
@ -42,7 +46,8 @@ class SevenTVApi {
/// Returns a map of user IDS to a list of their 7TV badges.
Future<Map<String, List<ChatBadge>>> getBadges() async {
final url = Uri.parse('https://api.7tv.app/v2/badges?user_identifier=twitch_id');
final url =
Uri.parse('https://api.7tv.app/v2/badges?user_identifier=twitch_id');
final response = await _client.get(url);
if (response.statusCode == 200) {

View file

@ -18,15 +18,19 @@ class TwitchApi {
const TwitchApi(this._client);
/// Returns a list of all Twitch global emotes.
Future<List<Emote>> getEmotesGlobal({required Map<String, String> headers}) async {
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();
final emotes =
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
return emotes.map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)).toList();
return emotes
.map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal))
.toList();
} else {
return Future.error('Failed to get Twitch global emotes');
}
@ -37,12 +41,14 @@ class TwitchApi {
required String id,
required Map<String, String> headers,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes?broadcaster_id=$id');
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();
final emotes =
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
return emotes.map((emote) {
switch (emote.emoteType) {
@ -66,12 +72,14 @@ class TwitchApi {
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 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();
final emotes =
decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList();
return emotes.map((emote) {
switch (emote.emoteType) {
@ -90,7 +98,8 @@ class TwitchApi {
}
/// Returns a map of global Twitch badges to their [Emote] object.
Future<Map<String, ChatBadge>> getBadgesGlobal({required Map<String, String> headers}) async {
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);
@ -119,7 +128,8 @@ class TwitchApi {
required String id,
required Map<String, String> headers,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/chat/badges?broadcaster_id=$id');
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) {
@ -181,7 +191,8 @@ class TwitchApi {
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'});
final response =
await _client.get(url, headers: {'Authorization': 'Bearer $token'});
if (response.statusCode == 200) {
return true;
} else {
@ -194,8 +205,9 @@ class TwitchApi {
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 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);
@ -221,7 +233,8 @@ class TwitchApi {
if (response.statusCode == 200) {
return StreamsTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get followed streams: ${decoded['message']}');
return Future.error(
'Failed to get followed streams: ${decoded['message']}');
}
}
@ -240,7 +253,8 @@ class TwitchApi {
if (response.statusCode == 200) {
return StreamsTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get streams under category: ${decoded['message']}');
return Future.error(
'Failed to get streams under category: ${decoded['message']}');
}
}
@ -249,7 +263,8 @@ class TwitchApi {
required String userLogin,
required Map<String, String> headers,
}) async {
final uri = Uri.parse('https://api.twitch.tv/helix/streams?user_login=$userLogin');
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) {
@ -272,8 +287,9 @@ class TwitchApi {
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 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);
@ -296,7 +312,8 @@ class TwitchApi {
required String userId,
required Map<String, String> headers,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/channels?broadcaster_id=$userId');
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);
@ -318,7 +335,8 @@ class TwitchApi {
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 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) {
@ -344,7 +362,8 @@ class TwitchApi {
if (response.statusCode == 200) {
return CategoriesTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get top categories: ${decoded['message']}');
return Future.error(
'Failed to get top categories: ${decoded['message']}');
}
}
@ -389,7 +408,8 @@ class TwitchApi {
required String userId,
required Map<String, String> headers,
}) async {
final uri = Uri.parse('https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId');
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) {
@ -403,7 +423,8 @@ class TwitchApi {
/// Returns a [ChatUsers] object containing the names of chatters in the given [userLogin]'s chat.
Future<ChatUsers> getChatters({required String userLogin}) async {
final uri = Uri.parse('https://tmi.twitch.tv/group/user/$userLogin/chatters');
final uri =
Uri.parse('https://tmi.twitch.tv/group/user/$userLogin/chatters');
final response = await _client.get(uri);
if (response.statusCode == 200) {
@ -432,7 +453,8 @@ class TwitchApi {
final blockedList = decoded['data'] as List;
if (blockedList.isNotEmpty) {
final result = blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList();
final result =
blockedList.map((e) => UserBlockedTwitch.fromJson(e)).toList();
if (cursor != null) {
// Wait a bit (150 milliseconds) before recursively calling.
@ -441,7 +463,8 @@ class TwitchApi {
// 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));
result.addAll(await getUserBlockedList(
id: id, cursor: cursor, headers: headers));
}
return result;
@ -455,8 +478,10 @@ class TwitchApi {
}
// 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');
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) {
@ -467,8 +492,10 @@ class TwitchApi {
}
// 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');
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) {

View file

@ -47,7 +47,10 @@ void main() async {
autorun((_) => prefs.setString('settings', jsonEncode(settingsStore)));
// Initialize Sentry for crash reporting if enabled.
if (settingsStore.sendCrashLogs) await SentryFlutter.init((options) => options.tracesSampleRate = sampleRate);
if (settingsStore.sendCrashLogs) {
await SentryFlutter.init(
(options) => options.tracesSampleRate = sampleRate);
}
/// Initialize API services with a common client.
/// This will prevent every request from creating a new client instance.
@ -165,7 +168,8 @@ final darkTheme = ThemeData(
fontWeight: FontWeight.bold,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(backgroundColor: gray),
bottomNavigationBarTheme:
const BottomNavigationBarThemeData(backgroundColor: gray),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
@ -220,7 +224,8 @@ class MyApp extends StatelessWidget {
locale: DevicePreview.locale(context),
title: 'Frosty',
theme: lightTheme,
darkTheme: settingsStore.themeType == ThemeType.dark || settingsStore.themeType == ThemeType.system
darkTheme: settingsStore.themeType == ThemeType.dark ||
settingsStore.themeType == ThemeType.system
? darkTheme
: oledTheme,
themeMode: settingsStore.themeType == ThemeType.system

View file

@ -24,7 +24,8 @@ class BadgeInfoTwitch {
this.description,
);
factory BadgeInfoTwitch.fromJson(Map<String, dynamic> json) => _$BadgeInfoTwitchFromJson(json);
factory BadgeInfoTwitch.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoTwitchFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
@ -41,7 +42,8 @@ class BadgeInfoFFZ {
this.urls,
);
factory BadgeInfoFFZ.fromJson(Map<String, dynamic> json) => _$BadgeInfoFFZFromJson(json);
factory BadgeInfoFFZ.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoFFZFromJson(json);
}
@JsonSerializable(createToJson: false)
@ -59,7 +61,8 @@ class BadgeUrlsFFZ {
this.url4x,
);
factory BadgeUrlsFFZ.fromJson(Map<String, dynamic> json) => _$BadgeUrlsFFZFromJson(json);
factory BadgeUrlsFFZ.fromJson(Map<String, dynamic> json) =>
_$BadgeUrlsFFZFromJson(json);
}
@JsonSerializable(createToJson: false)
@ -74,7 +77,8 @@ class BadgeInfo7TV {
this.users,
);
factory BadgeInfo7TV.fromJson(Map<String, dynamic> json) => _$BadgeInfo7TVFromJson(json);
factory BadgeInfo7TV.fromJson(Map<String, dynamic> json) =>
_$BadgeInfo7TVFromJson(json);
}
@JsonSerializable(createToJson: false)
@ -87,7 +91,8 @@ class BadgeInfoBTTV {
this.badge,
);
factory BadgeInfoBTTV.fromJson(Map<String, dynamic> json) => _$BadgeInfoBTTVFromJson(json);
factory BadgeInfoBTTV.fromJson(Map<String, dynamic> json) =>
_$BadgeInfoBTTVFromJson(json);
}
@JsonSerializable(createToJson: false)
@ -100,7 +105,8 @@ class BadgeDetailsBTTV {
this.svg,
);
factory BadgeDetailsBTTV.fromJson(Map<String, dynamic> json) => _$BadgeDetailsBTTVFromJson(json);
factory BadgeDetailsBTTV.fromJson(Map<String, dynamic> json) =>
_$BadgeDetailsBTTVFromJson(json);
}
class ChatBadge {

View file

@ -14,7 +14,8 @@ class CategoryTwitch {
this.name,
);
factory CategoryTwitch.fromJson(Map<String, dynamic> json) => _$CategoryTwitchFromJson(json);
factory CategoryTwitch.fromJson(Map<String, dynamic> json) =>
_$CategoryTwitchFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
@ -27,5 +28,6 @@ class CategoriesTwitch {
this.pagination,
);
factory CategoriesTwitch.fromJson(Map<String, dynamic> json) => _$CategoriesTwitchFromJson(json);
factory CategoriesTwitch.fromJson(Map<String, dynamic> json) =>
_$CategoriesTwitchFromJson(json);
}

View file

@ -15,7 +15,8 @@ class Channel {
required this.broadcasterName,
});
factory Channel.fromJson(Map<String, dynamic> json) => _$ChannelFromJson(json);
factory Channel.fromJson(Map<String, dynamic> json) =>
_$ChannelFromJson(json);
}
// Object for Twitch channel search query.
@ -35,5 +36,6 @@ class ChannelQuery {
required this.startedAt,
});
factory ChannelQuery.fromJson(Map<String, dynamic> json) => _$ChannelQueryFromJson(json);
factory ChannelQuery.fromJson(Map<String, dynamic> json) =>
_$ChannelQueryFromJson(json);
}

View file

@ -12,7 +12,8 @@ class ChatUsers {
this.chatters,
);
factory ChatUsers.fromJson(Map<String, dynamic> json) => _$ChatUsersFromJson(json);
factory ChatUsers.fromJson(Map<String, dynamic> json) =>
_$ChatUsersFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
@ -35,5 +36,6 @@ class Chatters {
this.viewers,
);
factory Chatters.fromJson(Map<String, dynamic> json) => _$ChattersFromJson(json);
factory Chatters.fromJson(Map<String, dynamic> json) =>
_$ChattersFromJson(json);
}

View file

@ -18,7 +18,8 @@ class EmoteTwitch {
this.ownerId,
);
factory EmoteTwitch.fromJson(Map<String, dynamic> json) => _$EmoteTwitchFromJson(json);
factory EmoteTwitch.fromJson(Map<String, dynamic> json) =>
_$EmoteTwitchFromJson(json);
}
// * BTTV Emotes *
@ -32,7 +33,8 @@ class EmoteBTTV {
this.code,
);
factory EmoteBTTV.fromJson(Map<String, dynamic> json) => _$EmoteBTTVFromJson(json);
factory EmoteBTTV.fromJson(Map<String, dynamic> json) =>
_$EmoteBTTVFromJson(json);
}
@JsonSerializable(createToJson: false)
@ -45,7 +47,8 @@ class EmoteBTTVChannel {
this.sharedEmotes,
);
factory EmoteBTTVChannel.fromJson(Map<String, dynamic> json) => _$EmoteBTTVChannelFromJson(json);
factory EmoteBTTVChannel.fromJson(Map<String, dynamic> json) =>
_$EmoteBTTVChannelFromJson(json);
}
// * FFZ Emotes *
@ -61,7 +64,8 @@ class RoomFFZ {
this.modUrls,
);
factory RoomFFZ.fromJson(Map<String, dynamic> json) => _$RoomFFZFromJson(json);
factory RoomFFZ.fromJson(Map<String, dynamic> json) =>
_$RoomFFZFromJson(json);
}
@JsonSerializable(createToJson: false)
@ -79,7 +83,8 @@ class ImagesFFZ {
this.url4x,
);
factory ImagesFFZ.fromJson(Map<String, dynamic> json) => _$ImagesFFZFromJson(json);
factory ImagesFFZ.fromJson(Map<String, dynamic> json) =>
_$ImagesFFZFromJson(json);
}
@JsonSerializable(createToJson: false)
@ -96,7 +101,8 @@ class EmoteFFZ {
this.urls,
);
factory EmoteFFZ.fromJson(Map<String, dynamic> json) => _$EmoteFFZFromJson(json);
factory EmoteFFZ.fromJson(Map<String, dynamic> json) =>
_$EmoteFFZFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
@ -115,7 +121,8 @@ class Emote7TV {
this.urls,
);
factory Emote7TV.fromJson(Map<String, dynamic> json) => _$Emote7TVFromJson(json);
factory Emote7TV.fromJson(Map<String, dynamic> json) =>
_$Emote7TVFromJson(json);
}
/// The common emote class.
@ -142,7 +149,8 @@ class Emote {
factory Emote.fromTwitch(EmoteTwitch emote, EmoteType type) => Emote(
name: emote.name,
zeroWidth: false,
url: 'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0',
url:
'https://static-cdn.jtvnw.net/emoticons/v2/${emote.id}/default/dark/3.0',
type: type,
ownerId: emote.ownerId,
);

View file

@ -45,7 +45,8 @@ class IRCMessage {
if (ircMessage.message == null) {
messages.clear();
bufferedMessages.clear();
messages.add(IRCMessage.createNotice(message: 'Chat was cleared by a moderator'));
messages.add(
IRCMessage.createNotice(message: 'Chat was cleared by a moderator'));
return;
}
@ -130,7 +131,8 @@ class IRCMessage {
final span = <InlineSpan>[];
if (timestamp != TimestampType.disabled) {
final time = tags['tmi-sent-ts'] ?? DateTime.now().millisecondsSinceEpoch.toString();
final time = tags['tmi-sent-ts'] ??
DateTime.now().millisecondsSinceEpoch.toString();
final parsedTime = DateTime.fromMillisecondsSinceEpoch(int.parse(time));
if (timestamp == TimestampType.twentyFour) {
@ -165,16 +167,20 @@ class IRCMessage {
var badgeUrl = badgeInfo.url;
// Add custom FFZ mod badge if it exists.
if (badgeInfo.name == 'Moderator' && (ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) {
if (badgeInfo.name == 'Moderator' &&
(ffzUserBadges != null || ffzRoomInfo?.modUrls != null)) {
// Check if mod is bot.
final botBadge = ffzUserBadges?.firstWhereOrNull((element) => element.name == 'Bot');
final botBadge = ffzUserBadges
?.firstWhereOrNull((element) => element.name == 'Bot');
// Check if user has bot badge or room has custom FFZ mod badges
if (botBadge != null) {
badgeUrl = botBadge.url;
skipBot = true;
} else if (ffzRoomInfo?.modUrls != null) {
badgeUrl = ffzRoomInfo!.modUrls?.url4x ?? ffzRoomInfo.modUrls?.url2x ?? ffzRoomInfo.modUrls!.url1x;
badgeUrl = ffzRoomInfo!.modUrls?.url4x ??
ffzRoomInfo.modUrls?.url2x ??
ffzRoomInfo.modUrls!.url1x;
}
final newBadge = ChatBadge(
@ -196,7 +202,9 @@ class IRCMessage {
// Add custom FFZ vip badge if it exists
if (badgeInfo.name == 'VIP' && ffzRoomInfo?.vipBadge != null) {
badgeUrl = ffzRoomInfo!.vipBadge?.url4x ?? ffzRoomInfo.vipBadge?.url2x ?? ffzRoomInfo.vipBadge!.url1x;
badgeUrl = ffzRoomInfo!.vipBadge?.url4x ??
ffzRoomInfo.vipBadge?.url2x ??
ffzRoomInfo.vipBadge!.url1x;
}
final newBadge = ChatBadge(
@ -273,14 +281,23 @@ class IRCMessage {
}
}
var color = Color(int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')));
var color = Color(
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')));
if (useReadableColors) {
final hsl = HSLColor.fromColor(color);
if (isLightTheme == true) {
if (hsl.lightness >= 0.5) color = hsl.withLightness(hsl.lightness + ((0 - hsl.lightness) * 0.5)).toColor();
if (hsl.lightness >= 0.5) {
color = hsl
.withLightness(hsl.lightness + ((0 - hsl.lightness) * 0.5))
.toColor();
}
} else {
if (hsl.lightness <= 0.5) color = hsl.withLightness(hsl.lightness + ((1 - hsl.lightness) * 0.5)).toColor();
if (hsl.lightness <= 0.5) {
color = hsl
.withLightness(hsl.lightness + ((1 - hsl.lightness) * 0.5))
.toColor();
}
}
}
@ -288,7 +305,9 @@ class IRCMessage {
final displayName = tags['display-name']!;
span.add(
TextSpan(
text: regexEnglish.hasMatch(displayName) ? displayName : '$displayName ($user)',
text: regexEnglish.hasMatch(displayName)
? displayName
: '$displayName ($user)',
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
@ -301,13 +320,16 @@ class IRCMessage {
if (action == false) span.add(const TextSpan(text: ':'));
// Italicize the text if it was called with an IRC Action (e.g., "/me").
final textStyle = action == true ? const TextStyle(fontStyle: FontStyle.italic) : style;
final textStyle =
action == true ? const TextStyle(fontStyle: FontStyle.italic) : style;
if (!showMessage) {
span.add(const TextSpan(text: ' <message deleted>'));
} else {
// Check if the message is a reply. If it is, remove the reply username (first word) from the message.
final words = tags.containsKey('reply-parent-display-name') ? split?.sublist(1) : split;
final words = tags.containsKey('reply-parent-display-name')
? split?.sublist(1)
: split;
// Add the message and any emotes to the span.
if (words != null) {
@ -334,7 +356,8 @@ class IRCMessage {
while (nextEmote != null && nextEmote.zeroWidth && index != 0) {
emoteStack.add(nextEmote);
index--;
nextEmote = emoteToObject[words[index]] ?? localEmotes?[words[index]];
nextEmote =
emoteToObject[words[index]] ?? localEmotes?[words[index]];
}
// If there's one more emote thats NOT zero-width, add it to the base of the stack.
@ -354,15 +377,20 @@ class IRCMessage {
...emoteStack.reversed.map(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null
? emote.width! * emoteScale
: emoteSize,
useFade: false,
),
)
];
// Create the message for the tooltip
final message = emoteStack.reversed.map((emote) => '${emote.name} - ${emoteType[emote.type.index]}');
final message = emoteStack.reversed.map(
(emote) => '${emote.name} - ${emoteType[emote.type.index]}');
localSpan.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
@ -399,7 +427,9 @@ class IRCMessage {
style: const TextStyle(color: Colors.white),
),
Text(
nextWordIsEmoji ? message.join(', ') : message.skip(1).join(', '),
nextWordIsEmoji
? message.join(', ')
: message.skip(1).join(', '),
style: const TextStyle(color: Colors.white),
),
],
@ -419,22 +449,32 @@ class IRCMessage {
// If the next word is neither an emote or emoji, add it as a text span.
if (nextEmote == null && !nextWordIsEmoji) {
localSpan.add(const TextSpan(text: ' '));
localSpan.add(_createTextSpan(text: words[index], style: textStyle, launchExternal: launchExternal));
localSpan.add(_createTextSpan(
text: words[index],
style: textStyle,
launchExternal: launchExternal));
}
} else {
localSpan.add(
_createEmoteSpan(
emote: emote,
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
),
);
}
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(_createEmojiSpan(emoji: word, style: textStyle?.copyWith(fontSize: emoteSize - 5)));
localSpan.add(_createEmojiSpan(
emoji: word,
style: textStyle?.copyWith(fontSize: emoteSize - 5)));
} else {
localSpan.add(_createTextSpan(text: word, style: textStyle, launchExternal: launchExternal));
localSpan.add(_createTextSpan(
text: word,
style: textStyle,
launchExternal: launchExternal));
}
}
@ -576,16 +616,20 @@ class IRCMessage {
);
}
static TextSpan _createTextSpan({required String text, required bool launchExternal, TextStyle? style}) {
static TextSpan _createTextSpan(
{required String text, required bool launchExternal, TextStyle? style}) {
if (text.startsWith('@')) {
return TextSpan(text: text, style: style?.copyWith(fontWeight: FontWeight.bold));
return TextSpan(
text: text, style: style?.copyWith(fontWeight: FontWeight.bold));
} else if (RegExp(r'https?:\/\/').hasMatch(text)) {
return TextSpan(
text: text,
style: style?.copyWith(color: Colors.blue),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(text),
mode: launchExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
mode: launchExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
);
} else {
return TextSpan(text: text, style: style);
@ -607,7 +651,8 @@ class IRCMessage {
// Get the tags substring and escape characters.
// IRC messages escape spaces with \s.
final tags = whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' ');
final tags =
whole.substring(1, tagAndIrcMessageDivider).replaceAll('\\s', ' ');
// Next, parse and map the tags.
final mappedTags = <String, String>{};
@ -634,15 +679,18 @@ class IRCMessage {
// If the username exists, set it.
// tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant.
final String? user =
splitMessage[0] == 'tmi.twitch.tv' ? null : splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
final String? user = splitMessage[0] == 'tmi.twitch.tv'
? null
: splitMessage[0].substring(0, splitMessage[0].indexOf('!'));
// If there is an associated message, set it.
//
// Also remove any "INVALID/UNDEFINED" Unicode characters.
// Rendering this character on iOS shows a question mark inside a square.
// This character is used by some clients to bypass restrictions on repeating message.
var message = splitMessage.length > 3 ? splitMessage.sublist(3).join(' ').substring(1) : null;
var message = splitMessage.length > 3
? splitMessage.sublist(3).join(' ').substring(1)
: null;
// Now process any Twitch emotes contained in the message tags.
// The map containing emotes from the user's tags to their URL.
@ -656,14 +704,16 @@ class IRCMessage {
for (final emoteIdAndPosition in emotes) {
final indexBetweenIdAndPositions = emoteIdAndPosition.indexOf(':');
final emoteId = emoteIdAndPosition.substring(0, indexBetweenIdAndPositions);
final emoteId =
emoteIdAndPosition.substring(0, indexBetweenIdAndPositions);
// Parse the range in order to extract the associated word.
// If there are more than one indices, use the first one.
// Else, use the one provided indices.
final String range;
if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(','));
range = emoteIdAndPosition.substring(
indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(','));
} else {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1);
}
@ -679,7 +729,8 @@ class IRCMessage {
localEmotes[emoteWord] = Emote(
name: emoteWord,
zeroWidth: false,
url: 'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0',
url:
'https://static-cdn.jtvnw.net/emoticons/v2/$emoteId/default/dark/3.0',
type: EmoteType.twitchSub,
);
}
@ -696,7 +747,9 @@ class IRCMessage {
}
// Check if the message mentions the logged-in user
if (userLogin != null) mention = message.toLowerCase().contains(userLogin);
if (userLogin != null) {
mention = message.toLowerCase().contains(userLogin);
}
// Escape the message
message = message
@ -725,7 +778,10 @@ class IRCMessage {
}
}
if (wordBuffer.isNotEmpty) split.addAll(wordBuffer.toString().split(' ').where((element) => element != ''));
if (wordBuffer.isNotEmpty) {
split.addAll(
wordBuffer.toString().split(' ').where((element) => element != ''));
}
if (emojiBuffer.isNotEmpty) split.add(emojiBuffer.toString());
}

View file

@ -27,7 +27,8 @@ class StreamTwitch {
this.thumbnailUrl,
);
factory StreamTwitch.fromJson(Map<String, dynamic> json) => _$StreamTwitchFromJson(json);
factory StreamTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamTwitchFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
@ -40,5 +41,6 @@ class StreamsTwitch {
this.pagination,
);
factory StreamsTwitch.fromJson(Map<String, dynamic> json) => _$StreamsTwitchFromJson(json);
factory StreamsTwitch.fromJson(Map<String, dynamic> json) =>
_$StreamsTwitchFromJson(json);
}

View file

@ -17,7 +17,8 @@ class UserTwitch {
this.profileImageUrl,
);
factory UserTwitch.fromJson(Map<String, dynamic> json) => _$UserTwitchFromJson(json);
factory UserTwitch.fromJson(Map<String, dynamic> json) =>
_$UserTwitchFromJson(json);
}
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
@ -32,5 +33,6 @@ class UserBlockedTwitch {
this.displayName,
);
factory UserBlockedTwitch.fromJson(Map<String, dynamic> json) => _$UserBlockedTwitchFromJson(json);
factory UserBlockedTwitch.fromJson(Map<String, dynamic> json) =>
_$UserBlockedTwitchFromJson(json);
}

View file

@ -95,8 +95,9 @@ class _VideoChatState extends State<VideoChat> {
final overlay = GestureDetector(
onLongPress: _videoStore.handleToggleOverlay,
onDoubleTap:
orientation == Orientation.landscape ? () => settingsStore.fullScreen = !settingsStore.fullScreen : null,
onDoubleTap: orientation == Orientation.landscape
? () => settingsStore.fullScreen = !settingsStore.fullScreen
: null,
onTap: () {
if (_chatStore.assetsStore.showEmoteMenu) {
_chatStore.assetsStore.showEmoteMenu = false;
@ -115,7 +116,9 @@ class _VideoChatState extends State<VideoChat> {
chatStore: _chatStore,
);
if (_videoStore.paused || _videoStore.streamInfo == null) return videoOverlay;
if (_videoStore.paused || _videoStore.streamInfo == null) {
return videoOverlay;
}
return AnimatedOpacity(
opacity: _videoStore.overlayVisible ? 1.0 : 0.0,
@ -189,16 +192,21 @@ class _VideoChatState extends State<VideoChat> {
switchOutCurve: Curves.easeIn,
child: _chatStore.notification != null
? Align(
alignment:
_chatStore.settings.chatNotificationsOnBottom ? Alignment.bottomCenter : Alignment.topCenter,
alignment: _chatStore.settings.chatNotificationsOnBottom
? Alignment.bottomCenter
: Alignment.topCenter,
child: FrostyNotification(
message: _chatStore.notification!,
showPasteButton: _chatStore.notification!.contains('copied'),
showPasteButton:
_chatStore.notification!.contains('copied'),
onButtonPressed: () async {
// Paste clipboard text into the text controller.
final data = await Clipboard.getData(Clipboard.kTextPlain);
final data =
await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) _chatStore.textController.text = data.text!;
if (data != null) {
_chatStore.textController.text = data.text!;
}
_chatStore.updateNotification('');
},
@ -214,7 +222,8 @@ class _VideoChatState extends State<VideoChat> {
final videoChat = Scaffold(
body: Observer(
builder: (context) {
if (orientation == Orientation.landscape && !settingsStore.landscapeForceVerticalChat) {
if (orientation == Orientation.landscape &&
!settingsStore.landscapeForceVerticalChat) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
final landscapeChat = AnimatedContainer(
@ -222,9 +231,11 @@ class _VideoChatState extends State<VideoChat> {
duration: const Duration(milliseconds: 200),
width: _chatStore.expandChat
? MediaQuery.of(context).size.width / 2
: MediaQuery.of(context).size.width * _chatStore.settings.chatWidth,
: MediaQuery.of(context).size.width *
_chatStore.settings.chatWidth,
color: _chatStore.settings.fullScreen
? Colors.black.withOpacity(_chatStore.settings.fullScreenChatOverlayOpacity)
? Colors.black.withOpacity(
_chatStore.settings.fullScreenChatOverlayOpacity)
: Theme.of(context).scaffoldBackgroundColor,
child: chat,
);
@ -235,22 +246,30 @@ class _VideoChatState extends State<VideoChat> {
child: Theme(
data: darkTheme,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(color: Colors.white),
style: DefaultTextStyle.of(context)
.style
.copyWith(color: Colors.white),
child: landscapeChat,
),
),
);
return ColoredBox(
color: settingsStore.showVideo ? Colors.black : Theme.of(context).scaffoldBackgroundColor,
color: settingsStore.showVideo
? Colors.black
: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
bottom: false,
left: (settingsStore.landscapeCutout == LandscapeCutoutType.both ||
settingsStore.landscapeCutout == LandscapeCutoutType.left)
left: (settingsStore.landscapeCutout ==
LandscapeCutoutType.both ||
settingsStore.landscapeCutout ==
LandscapeCutoutType.left)
? false
: true,
right: (settingsStore.landscapeCutout == LandscapeCutoutType.both ||
settingsStore.landscapeCutout == LandscapeCutoutType.right)
right: (settingsStore.landscapeCutout ==
LandscapeCutoutType.both ||
settingsStore.landscapeCutout ==
LandscapeCutoutType.right)
? false
: true,
child: settingsStore.showVideo
@ -285,7 +304,10 @@ class _VideoChatState extends State<VideoChat> {
return SafeArea(
child: Column(
children: [
if (!settingsStore.showVideo) appBar else AspectRatio(aspectRatio: 16 / 9, child: video),
if (!settingsStore.showVideo)
appBar
else
AspectRatio(aspectRatio: 16 / 9, child: video),
Expanded(child: chat),
],
),

View file

@ -34,9 +34,12 @@ class Chat extends StatelessWidget {
alignment: AlignmentDirectional.bottomCenter,
children: [
MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: chatStore.settings.messageScale),
data: MediaQuery.of(context).copyWith(
textScaleFactor: chatStore.settings.messageScale),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize),
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
child: Observer(
builder: (context) {
return ListView.builder(
@ -46,7 +49,8 @@ class Chat extends StatelessWidget {
controller: chatStore.scrollController,
itemCount: chatStore.renderMessages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage: chatStore.renderMessages.reversed.toList()[index],
ircMessage: chatStore.renderMessages.reversed
.toList()[index],
chatStore: chatStore,
),
);
@ -64,9 +68,11 @@ class Chat extends StatelessWidget {
child: chatStore.autoScroll
? null
: Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 10.0),
padding: const EdgeInsets.symmetric(
horizontal: 15.0, vertical: 10.0),
onPressed: chatStore.resumeScroll,
icon: const Icon(Icons.keyboard_double_arrow_down_rounded),
icon: const Icon(
Icons.keyboard_double_arrow_down_rounded),
child: const Text('Resume scroll'),
),
),
@ -76,7 +82,8 @@ class Chat extends StatelessWidget {
),
),
),
if (chatStore.settings.showBottomBar) ChatBottomBar(chatStore: chatStore),
if (chatStore.settings.showBottomBar)
ChatBottomBar(chatStore: chatStore),
WillPopScope(
onWillPop: Platform.isAndroid
? () async {
@ -94,7 +101,10 @@ class Chat extends StatelessWidget {
duration: const Duration(milliseconds: 200),
height: chatStore.assetsStore.showEmoteMenu
? MediaQuery.of(context).size.height /
(MediaQuery.of(context).orientation == Orientation.portrait ? 3 : 2)
(MediaQuery.of(context).orientation ==
Orientation.portrait
? 3
: 2)
: 0,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
@ -115,7 +125,8 @@ class Chat extends StatelessWidget {
),
EmoteMenuPanel(
chatStore: chatStore,
twitchEmotes: chatStore.assetsStore.userEmoteSectionToEmotes,
twitchEmotes: chatStore
.assetsStore.userEmoteSectionToEmotes,
),
...[
chatStore.assetsStore.sevenTVEmotes,

View file

@ -40,13 +40,18 @@ class _ChatDetailsState extends State<ChatDetails> {
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: widget.chatStore.sleepTimer != null && widget.chatStore.sleepTimer!.isActive ? 1.0 : 0.5,
opacity: widget.chatStore.sleepTimer != null &&
widget.chatStore.sleepTimer!.isActive
? 1.0
: 0.5,
child: Row(
children: [
const Icon(Icons.timer_rounded),
Text(
' ${widget.chatStore.timeRemaining.toString().split('.')[0]}',
style: const TextStyle(fontWeight: FontWeight.w600, fontFeatures: [FontFeature.tabularFigures()]),
style: const TextStyle(
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()]),
),
const Spacer(),
IconButton(
@ -62,9 +67,11 @@ class _ChatDetailsState extends State<ChatDetails> {
DropdownButton(
value: widget.chatStore.sleepHours,
items: List.generate(24, (index) => index)
.map((e) => DropdownMenuItem(value: e, child: Text(e.toString())))
.map((e) => DropdownMenuItem(
value: e, child: Text(e.toString())))
.toList(),
onChanged: (int? hours) => widget.chatStore.sleepHours = hours!,
onChanged: (int? hours) =>
widget.chatStore.sleepHours = hours!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
@ -76,9 +83,11 @@ class _ChatDetailsState extends State<ChatDetails> {
DropdownButton(
value: widget.chatStore.sleepMinutes,
items: List.generate(60, (index) => index)
.map((e) => DropdownMenuItem(value: e, child: Text(e.toString())))
.map((e) => DropdownMenuItem(
value: e, child: Text(e.toString())))
.toList(),
onChanged: (int? minutes) => widget.chatStore.sleepMinutes = minutes!,
onChanged: (int? minutes) =>
widget.chatStore.sleepMinutes = minutes!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
@ -91,10 +100,12 @@ class _ChatDetailsState extends State<ChatDetails> {
actions: [
Observer(
builder: (context) => Button(
onPressed: widget.chatStore.sleepHours == 0 && widget.chatStore.sleepMinutes == 0
onPressed: widget.chatStore.sleepHours == 0 &&
widget.chatStore.sleepMinutes == 0
? null
: () => widget.chatStore.updateSleepTimer(
onTimerFinished: () => navigatorKey.currentState?.popUntil((route) => route.isFirst),
onTimerFinished: () => navigatorKey.currentState
?.popUntil((route) => route.isFirst),
),
child: const Text('Set timer'),
),
@ -201,7 +212,8 @@ class _ChatDetailsState extends State<ChatDetails> {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Settings(settingsStore: widget.chatStore.settings),
builder: (context) =>
Settings(settingsStore: widget.chatStore.settings),
),
),
),
@ -215,7 +227,8 @@ class _ChatDetailsState extends State<ChatDetails> {
children: children,
),
)
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: children),
);
}
}

View file

@ -67,7 +67,8 @@ abstract class ChatDetailsStoreBase with Store {
ChatDetailsStoreBase({required this.twitchApi, required this.channelName}) {
scrollController.addListener(() {
if (scrollController.position.atEdge || scrollController.position.outOfRange) {
if (scrollController.position.atEdge ||
scrollController.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;

View file

@ -17,9 +17,12 @@ class ChatModes extends StatelessWidget {
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message: 'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}',
message:
'Emote-only mode ${roomState.emoteOnly != '0' ? 'on' : 'off'}',
child: Icon(
roomState.emoteOnly != '0' ? Icons.emoji_emotions_rounded : Icons.emoji_emotions_outlined,
roomState.emoteOnly != '0'
? Icons.emoji_emotions_rounded
: Icons.emoji_emotions_outlined,
color: roomState.emoteOnly != '0' ? Colors.yellow : Colors.grey,
),
),
@ -33,7 +36,9 @@ class ChatModes extends StatelessWidget {
? 'Followers-only mode on'
: 'Followers-only mode on (${roomState.followersOnly} ${pluralize('minute', roomState.followersOnly)})',
child: Icon(
roomState.followersOnly != '-1' ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
roomState.followersOnly != '-1'
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
color: roomState.followersOnly != '-1' ? Colors.red : Colors.grey,
),
),
@ -41,7 +46,8 @@ class ChatModes extends StatelessWidget {
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
message: 'R9K (unique-chat) mode ${roomState.r9k != '0' ? 'on' : 'off'}',
message:
'R9K (unique-chat) mode ${roomState.r9k != '0' ? 'on' : 'off'}',
child: Text(
'R9K',
style: TextStyle(
@ -57,7 +63,9 @@ class ChatModes extends StatelessWidget {
message:
'Slow mode ${roomState.slowMode != '0' ? 'on (${roomState.slowMode} ${pluralize('second', roomState.slowMode)})' : 'off'}',
child: Icon(
roomState.slowMode != '0' ? Icons.hourglass_top_rounded : Icons.hourglass_empty_rounded,
roomState.slowMode != '0'
? Icons.hourglass_top_rounded
: Icons.hourglass_empty_rounded,
color: roomState.slowMode != '0' ? Colors.blue : Colors.grey,
),
),
@ -67,7 +75,9 @@ class ChatModes extends StatelessWidget {
showDuration: const Duration(seconds: 3),
message: 'Subs-only mode ${roomState.subMode != '0' ? 'on' : 'off'}',
child: Icon(
roomState.subMode != '0' ? Icons.monetization_on_rounded : Icons.monetization_on_outlined,
roomState.subMode != '0'
? Icons.monetization_on_rounded
: Icons.monetization_on_outlined,
color: roomState.subMode != '0' ? Colors.green : Colors.grey,
),
),

View file

@ -81,13 +81,19 @@ class _ChattersListState extends State<ChattersList> {
contentPadding: EdgeInsets.zero,
prefixIcon: const Icon(Icons.filter_list_rounded),
hintText: 'Filter chatters',
suffixIcon: widget.chatDetailsStore.textFieldFocusNode.hasFocus ||
suffixIcon: widget.chatDetailsStore.textFieldFocusNode
.hasFocus ||
widget.chatDetailsStore.filterText.isNotEmpty
? IconButton(
tooltip: widget.chatDetailsStore.filterText.isEmpty ? 'Cancel' : 'Clear',
tooltip:
widget.chatDetailsStore.filterText.isEmpty
? 'Cancel'
: 'Clear',
onPressed: () {
if (widget.chatDetailsStore.filterText.isEmpty) {
widget.chatDetailsStore.textFieldFocusNode.unfocus();
if (widget
.chatDetailsStore.filterText.isEmpty) {
widget.chatDetailsStore.textFieldFocusNode
.unfocus();
}
widget.chatDetailsStore.textController.clear();
},
@ -117,7 +123,8 @@ class _ChattersListState extends State<ChattersList> {
slivers: [
if (widget.chatDetailsStore.filterText.isEmpty)
SliverPadding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0),
padding: const EdgeInsets.only(
left: 10.0, right: 10.0, top: 20.0),
sliver: SliverToBoxAdapter(
child: Text(
'${NumberFormat().format(widget.chatDetailsStore.chatUsers?.chatterCount)} in chat',
@ -128,30 +135,39 @@ class _ChattersListState extends State<ChattersList> {
),
),
),
if (widget.chatDetailsStore.chatUsers?.chatterCount == 0)
if (widget
.chatDetailsStore.chatUsers?.chatterCount ==
0)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: AlertMessage(message: 'No chatters found'),
child: AlertMessage(
message: 'No chatters found'),
),
)
else if (widget.chatDetailsStore.filteredUsers.expand((element) => element).isEmpty)
else if (widget.chatDetailsStore.filteredUsers
.expand((element) => element)
.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: AlertMessage(message: 'No matching chatters'),
child: AlertMessage(
message: 'No matching chatters'),
),
)
else
...widget.chatDetailsStore.filteredUsers.expandIndexed(
...widget.chatDetailsStore.filteredUsers
.expandIndexed(
(index, users) => [
if (users.isNotEmpty) ...[
SliverPadding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 20.0),
padding: const EdgeInsets.only(
left: 10.0, right: 10.0, top: 20.0),
sliver: SliverToBoxAdapter(
child: SectionHeader(
headers[index],
padding: const EdgeInsets.symmetric(vertical: 5.0),
padding: const EdgeInsets.symmetric(
vertical: 5.0),
),
),
),
@ -159,27 +175,36 @@ class _ChattersListState extends State<ChattersList> {
delegate: SliverChildBuilderDelegate(
(context, index) => InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0),
padding: const EdgeInsets.symmetric(
horizontal: 10.0,
vertical: 5.0),
child: Text(users[index]),
),
onLongPress: () async {
HapticFeedback.lightImpact();
final userInfo = await context.read<TwitchApi>().getUser(
headers: context.read<AuthStore>().headersTwitch,
userLogin: users[index]);
final userInfo = await context
.read<TwitchApi>()
.getUser(
headers: context
.read<AuthStore>()
.headersTwitch,
userLogin: users[index]);
if (!mounted) return;
showModalBottomSheet(
backgroundColor: Colors.transparent,
backgroundColor:
Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
builder: (context) =>
ChatUserModal(
chatStore: widget.chatStore,
username: userInfo.login,
userId: userInfo.id,
displayName: userInfo.displayName,
displayName:
userInfo.displayName,
),
);
},
@ -200,7 +225,9 @@ class _ChattersListState extends State<ChattersList> {
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: widget.chatDetailsStore.showJumpButton
? ScrollToTopButton(scrollController: widget.chatDetailsStore.scrollController)
? ScrollToTopButton(
scrollController:
widget.chatDetailsStore.scrollController)
: null,
),
),

View file

@ -50,8 +50,11 @@ class EmoteMenuPanel extends StatelessWidget {
);
} else {
return FrostyPageView(
headers: twitchEmotes!.keys.map((header) => header.split(' ')[0]).toList(),
children: twitchEmotes!.entries.map((e) => EmoteMenuSection(chatStore: chatStore, emotes: e.value)).toList(),
headers:
twitchEmotes!.keys.map((header) => header.split(' ')[0]).toList(),
children: twitchEmotes!.entries
.map((e) => EmoteMenuSection(chatStore: chatStore, emotes: e.value))
.toList(),
);
}
}

View file

@ -20,11 +20,12 @@ class EmoteMenuSection extends StatelessWidget {
Widget build(BuildContext context) {
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
crossAxisCount:
MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
),
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(emotes[index]),

View file

@ -26,11 +26,12 @@ class RecentEmotesPanel extends StatelessWidget {
else
SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
crossAxisCount:
MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
@ -39,11 +40,14 @@ class RecentEmotesPanel extends StatelessWidget {
...chatStore.assetsStore.emoteToObject.values,
...chatStore.assetsStore.userEmoteToObject.values
];
final matchingEmotes = validEmotes
.where((existingEmote) => existingEmote.name == emote.name && existingEmote.type == emote.type);
final matchingEmotes = validEmotes.where((existingEmote) =>
existingEmote.name == emote.name &&
existingEmote.type == emote.type);
return InkWell(
onTap: matchingEmotes.isNotEmpty ? () => chatStore.addEmote(emote) : null,
onTap: matchingEmotes.isNotEmpty
? () => chatStore.addEmote(emote)
: null,
child: Tooltip(
message: emote.name,
preferBelow: false,
@ -51,9 +55,15 @@ class RecentEmotesPanel extends StatelessWidget {
padding: const EdgeInsets.all(10.0),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes.isNotEmpty ? matchingEmotes.first.url : emote.url,
color: matchingEmotes.isNotEmpty ? null : const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: matchingEmotes.isNotEmpty ? null : BlendMode.modulate,
imageUrl: matchingEmotes.isNotEmpty
? matchingEmotes.first.url
: emote.url,
color: matchingEmotes.isNotEmpty
? null
: const Color.fromRGBO(255, 255, 255, 0.5),
colorBlendMode: matchingEmotes.isNotEmpty
? null
: BlendMode.modulate,
height: emote.height?.toDouble() ?? defaultEmoteSize,
width: emote.width?.toDouble(),
),

View file

@ -23,13 +23,16 @@ abstract class ChatAssetsStoreBase with Store {
RoomFFZ? ffzRoomInfo;
@computed
List<Emote> get bttvEmotes => _emoteToObject.values.where((emote) => isBTTV(emote)).toList();
List<Emote> get bttvEmotes =>
_emoteToObject.values.where((emote) => isBTTV(emote)).toList();
@computed
List<Emote> get ffzEmotes => _emoteToObject.values.where((emote) => isFFZ(emote)).toList();
List<Emote> get ffzEmotes =>
_emoteToObject.values.where((emote) => isFFZ(emote)).toList();
@computed
List<Emote> get sevenTVEmotes => _emoteToObject.values.where((emote) => is7TV(emote)).toList();
List<Emote> get sevenTVEmotes =>
_emoteToObject.values.where((emote) => is7TV(emote)).toList();
/// The map of badges ids to their object representation.
final twitchBadgesToObject = ObservableMap<String, ChatBadge>();
@ -74,11 +77,13 @@ abstract class ChatAssetsStoreBase with Store {
}
bool isFFZ(Emote emote) {
return emote.type == EmoteType.ffzChannel || emote.type == EmoteType.ffzGlobal;
return emote.type == EmoteType.ffzChannel ||
emote.type == EmoteType.ffzGlobal;
}
bool is7TV(Emote emote) {
return emote.type == EmoteType.sevenTVChannel || emote.type == EmoteType.sevenTVGlobal;
return emote.type == EmoteType.sevenTVChannel ||
emote.type == EmoteType.sevenTVGlobal;
}
late final ReactionDisposer _disposeReaction;
@ -104,7 +109,8 @@ abstract class ChatAssetsStoreBase with Store {
_disposeReaction = autorun((_) {
if (_recentEmotes.length > 48) _recentEmotes.removeLast();
prefs.setStringList('recent_emotes', _recentEmotes.map((emote) => jsonEncode(emote)).toList());
prefs.setStringList('recent_emotes',
_recentEmotes.map((emote) => jsonEncode(emote)).toList());
});
}
@ -145,16 +151,17 @@ abstract class ChatAssetsStoreBase with Store {
bttvApi.getEmotesGlobal().catchError(onError),
bttvApi.getEmotesChannel(id: channelId).catchError(onError),
twitchApi.getEmotesGlobal(headers: headers).catchError(onError),
twitchApi.getEmotesChannel(id: channelId, headers: headers).catchError(onError),
twitchApi
.getEmotesChannel(id: channelId, headers: headers)
.catchError(onError),
sevenTVApi.getEmotesGlobal().catchError(onError),
sevenTVApi.getEmotesChannel(id: channelId).catchError(onError),
ffzApi.getRoomInfo(id: channelId).then((ffzRoom) {
ffzRoomInfo = ffzRoom.item1;
return ffzRoom.item2;
}).catchError(onError),
])
.then((assets) => assets.expand((list) => list))
.then((emotes) => _emoteToObject = {for (final emote in emotes) emote.name: emote});
]).then((assets) => assets.expand((list) => list)).then((emotes) =>
_emoteToObject = {for (final emote in emotes) emote.name: emote});
@action
Future<void> badgesFuture({
@ -165,14 +172,25 @@ abstract class ChatAssetsStoreBase with Store {
Future.wait([
// Get global badges first, then channel badges to avoid badge conflicts.
// We want the channel badges to override the global badges.
twitchApi.getBadgesGlobal(headers: headers).then((badges) => twitchBadgesToObject.addAll(badges)).then((_) =>
twitchApi
twitchApi
.getBadgesGlobal(headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.then((_) => twitchApi
.getBadgesChannel(id: channelId, headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.catchError(onError)),
ffzApi.getBadges().then((badges) => _userToFFZBadges = badges).catchError(onError),
sevenTVApi.getBadges().then((badges) => _userTo7TVBadges = badges).catchError(onError),
bttvApi.getBadges().then((badges) => _userToBTTVBadges = badges).catchError(onError),
ffzApi
.getBadges()
.then((badges) => _userToFFZBadges = badges)
.catchError(onError),
sevenTVApi
.getBadges()
.then((badges) => _userTo7TVBadges = badges)
.catchError(onError),
bttvApi
.getBadges()
.then((badges) => _userToBTTVBadges = badges)
.catchError(onError),
]);
@action
@ -181,13 +199,15 @@ abstract class ChatAssetsStoreBase with Store {
required Map<String, String> headers,
required Function onError,
}) async {
final userEmotes = await Future.wait(
emoteSets.map((setId) => twitchApi.getEmotesSets(setId: setId, headers: headers).catchError(onError)));
final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi
.getEmotesSets(setId: setId, headers: headers)
.catchError(onError)));
for (final emoteSet in userEmotes) {
if (emoteSet.isNotEmpty) {
if (emoteSet.first.type == EmoteType.twitchSub) {
final owner = await twitchApi.getUser(id: emoteSet.first.ownerId, headers: headers);
final owner = await twitchApi.getUser(
id: emoteSet.first.ownerId, headers: headers);
_userEmoteSectionToEmotes.update(
owner.displayName,
(existingEmoteSet) => [...existingEmoteSet, ...emoteSet],

View file

@ -113,7 +113,9 @@ abstract class ChatStoreBase with Store {
List<IRCMessage> get renderMessages {
// If autoscroll is disabled, render ALL messages in chat.
// The second condition is to prevent an out of index error with sublist.
if (!_autoScroll || _messages.length < _renderMessageLimit) return _messages;
if (!_autoScroll || _messages.length < _renderMessageLimit) {
return _messages;
}
// When autoscroll is enabled, only show the first [_renderMessageLimit] messages.
// This will improve performance by only rendering a limited amount of messages
@ -163,12 +165,14 @@ abstract class ChatStoreBase with Store {
);
// Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic(const Duration(milliseconds: 200), (timer) => addMessages());
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200), (timer) => addMessages());
assetsStore.init();
chatDetailsStore.updateChatters();
_messageBuffer.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
_messageBuffer
.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
if (settings.chatDelay > 0) {
_messageBuffer.add(IRCMessage.createNotice(
@ -205,11 +209,14 @@ abstract class ChatStoreBase with Store {
// Add a listener to the textfield that will show/hide the autocomplete bar if focused.
// Will also rebuild the autocomplete bar when typing, refreshing the results as the user types.
textController.addListener(() => _showEmoteAutocomplete =
!_showMentionAutocomplete && textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty);
!_showMentionAutocomplete &&
textFieldFocusNode.hasFocus &&
textController.text.split(' ').last.isNotEmpty);
textController.addListener(() {
_showSendButton = textController.text.isNotEmpty;
_showMentionAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.startsWith('@');
_showMentionAutocomplete = textFieldFocusNode.hasFocus &&
textController.text.split(' ').last.startsWith('@');
});
}
@ -224,12 +231,16 @@ abstract class ChatStoreBase with Store {
for (final message in data.trimRight().split('\r\n')) {
// debugPrint('$message\n');
if (message.startsWith('@')) {
final parsedIRCMessage = IRCMessage.fromString(message, userLogin: auth.user.details?.login);
final parsedIRCMessage =
IRCMessage.fromString(message, userLogin: auth.user.details?.login);
// Filter messages from any blocked users if not a moderator or not the channel owner.
if (!_userState.mod &&
channelName != auth.user.details?.login &&
auth.user.blockedUsers.where((blockedUser) => blockedUser.userLogin == parsedIRCMessage.user).isNotEmpty) {
auth.user.blockedUsers
.where((blockedUser) =>
blockedUser.userLogin == parsedIRCMessage.user)
.isNotEmpty) {
continue;
}
@ -254,7 +265,8 @@ abstract class ChatStoreBase with Store {
);
break;
case Command.roomState:
chatDetailsStore.roomState = chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage);
chatDetailsStore.roomState =
chatDetailsStore.roomState.fromIRCMessage(parsedIRCMessage);
continue;
case Command.userState:
_userState = _userState.fromIRCMessage(parsedIRCMessage);
@ -292,7 +304,9 @@ abstract class ChatStoreBase with Store {
}
// If the message limit is reached, remove the oldest messages.
if (_messages.length >= _messageLimit) _messages = _messages.sublist(_messagesToRemove).asObservable();
if (_messages.length >= _messageLimit) {
_messages = _messages.sublist(_messagesToRemove).asObservable();
}
} else if (message == 'PING :tmi.twitch.tv') {
_channel?.sink.add('PONG :tmi.twitch.tv');
return;
@ -342,11 +356,13 @@ abstract class ChatStoreBase with Store {
@action
void connectToChat() {
_channel?.sink.close(1001);
_channel = WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
_channel =
WebSocketChannel.connect(Uri.parse('wss://irc-ws.chat.twitch.tv:443'));
// Listen for new messages and forward them to the handler.
_channelListener = _channel?.stream.listen(
(data) => Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () => _handleIRCData(data.toString())),
(data) => Future.delayed(Duration(seconds: settings.chatDelay.toInt()),
() => _handleIRCData(data.toString())),
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
onDone: () async {
if (_channel == null) return;
@ -365,7 +381,8 @@ abstract class ChatStoreBase with Store {
// Increment the retry count and attempt the reconnect.
_retries++;
_messageBuffer.add(IRCMessage.createNotice(message: 'Reconnecting to chat (attempt $_retries)...'));
_messageBuffer.add(IRCMessage.createNotice(
message: 'Reconnecting to chat (attempt $_retries)...'));
_channelListener?.cancel();
connectToChat();
},
@ -408,7 +425,8 @@ abstract class ChatStoreBase with Store {
if (message.isEmpty) return;
if (_channel == null || _channel?.closeCode != null) {
_messageBuffer.add(IRCMessage.createNotice(message: 'Failed to send message: disconnected from chat.'));
_messageBuffer.add(IRCMessage.createNotice(
message: 'Failed to send message: disconnected from chat.'));
} else {
// Send the message to the IRC chat room.
_channel?.sink.add('PRIVMSG #$channelName :$message');
@ -417,14 +435,17 @@ abstract class ChatStoreBase with Store {
var userStateString = _userState.raw;
if (userStateString != null) {
if (message.length > 3 && message.substring(0, 3) == '/me') {
userStateString += ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
userStateString +=
' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01';
} else {
userStateString += ' :${message.trim()}';
}
final userChatMessage = IRCMessage.fromString(userStateString);
userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject);
if (auth.isLoggedIn && auth.user.details != null) userChatMessage.tags['user-id'] = auth.user.details!.id;
if (auth.isLoggedIn && auth.user.details != null) {
userChatMessage.tags['user-id'] = auth.user.details!.id;
}
toSend = userChatMessage;
}
@ -436,7 +457,9 @@ abstract class ChatStoreBase with Store {
void addEmote(Emote emote, {bool autocompleteMode = false}) {
if (textController.text.isEmpty || textController.text.endsWith(' ')) {
textController.text += '${emote.name} ';
} else if (autocompleteMode && _showEmoteAutocomplete && textController.text.endsWith('')) {
} else if (autocompleteMode &&
_showEmoteAutocomplete &&
textController.text.endsWith('')) {
final split = textController.text.split(' ')
..removeLast()
..add('${emote.name} ');
@ -447,10 +470,12 @@ abstract class ChatStoreBase with Store {
}
assetsStore.recentEmotes
..removeWhere((recentEmote) => recentEmote.name == emote.name && recentEmote.type == emote.type)
..removeWhere((recentEmote) =>
recentEmote.name == emote.name && recentEmote.type == emote.type)
..insert(0, emote);
textController.selection = TextSelection.fromPosition(TextPosition(offset: textController.text.length));
textController.selection = TextSelection.fromPosition(
TextPosition(offset: textController.text.length));
}
/// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage].
@ -468,7 +493,8 @@ abstract class ChatStoreBase with Store {
// Set the new notification message and create a new timer that will dismiss it after 2 seconds.
_notification = notificationMessage;
_notificationTimer = Timer(const Duration(seconds: 2), () => _notification = null);
_notificationTimer =
Timer(const Duration(seconds: 2), () => _notification = null);
}
/// Updates the sleep timer with [sleepHours] and [sleepMinutes].

View file

@ -17,11 +17,16 @@ class ChatBottomBar extends StatelessWidget {
message: 'Emote menu',
preferBelow: false,
child: IconButton(
color: chatStore.assetsStore.showEmoteMenu ? Theme.of(context).colorScheme.secondary : null,
icon: Icon(chatStore.assetsStore.showEmoteMenu ? Icons.emoji_emotions_rounded : Icons.emoji_emotions_outlined),
color: chatStore.assetsStore.showEmoteMenu
? Theme.of(context).colorScheme.secondary
: null,
icon: Icon(chatStore.assetsStore.showEmoteMenu
? Icons.emoji_emotions_rounded
: Icons.emoji_emotions_outlined),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu;
chatStore.assetsStore.showEmoteMenu =
!chatStore.assetsStore.showEmoteMenu;
},
),
);
@ -34,18 +39,23 @@ class ChatBottomBar extends StatelessWidget {
...chatStore.assetsStore.ffzEmotes,
...chatStore.assetsStore.sevenTVEmotes
]
.where((emote) =>
emote.name.toLowerCase().contains(chatStore.textController.text.split(' ').last.toLowerCase()))
.where((emote) => emote.name.toLowerCase().contains(
chatStore.textController.text.split(' ').last.toLowerCase()))
.toList();
final matchingChatters = chatStore.chatDetailsStore.allChatters
.where((chatter) =>
chatter.contains(chatStore.textController.text.split(' ').last.replaceFirst('@', '').toLowerCase()))
.where((chatter) => chatter.contains(chatStore.textController.text
.split(' ')
.last
.replaceFirst('@', '')
.toLowerCase()))
.toList();
return Column(
children: [
if (chatStore.settings.autocomplete && chatStore.showEmoteAutocomplete && matchingEmotes.isNotEmpty) ...[
if (chatStore.settings.autocomplete &&
chatStore.showEmoteAutocomplete &&
matchingEmotes.isNotEmpty) ...[
const Divider(
height: 1.0,
thickness: 1.0,
@ -57,7 +67,8 @@ class ChatBottomBar extends StatelessWidget {
itemCount: matchingEmotes.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(matchingEmotes[index], autocompleteMode: true),
onTap: () => chatStore.addEmote(matchingEmotes[index],
autocompleteMode: true),
child: Tooltip(
message: matchingEmotes[index].name,
preferBelow: false,
@ -67,7 +78,8 @@ class ChatBottomBar extends StatelessWidget {
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes[index].url,
useFade: false,
height: matchingEmotes[index].height?.toDouble() ?? defaultEmoteSize,
height: matchingEmotes[index].height?.toDouble() ??
defaultEmoteSize,
width: matchingEmotes[index].width?.toDouble(),
),
),
@ -99,7 +111,8 @@ class ChatBottomBar extends StatelessWidget {
chatStore.textController.text = split.join(' ');
chatStore.textController.selection =
TextSelection.fromPosition(TextPosition(offset: chatStore.textController.text.length));
TextSelection.fromPosition(TextPosition(
offset: chatStore.textController.text.length));
},
child: Text(matchingChatters[index]),
),
@ -113,7 +126,8 @@ class ChatBottomBar extends StatelessWidget {
if (!chatStore.expandChat &&
chatStore.settings.chatWidth < 0.3 &&
chatStore.settings.showVideo &&
MediaQuery.of(context).orientation == Orientation.landscape)
MediaQuery.of(context).orientation ==
Orientation.landscape)
IconButton(
tooltip: 'Enter a message',
onPressed: () {
@ -132,8 +146,12 @@ class ChatBottomBar extends StatelessWidget {
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
contentPadding: const EdgeInsets.only(left: 15.0),
prefixIcon: chatStore.settings.emoteMenuButtonOnLeft ? emoteMenuButton : null,
suffixIcon: chatStore.settings.emoteMenuButtonOnLeft ? null : emoteMenuButton,
prefixIcon: chatStore.settings.emoteMenuButtonOnLeft
? emoteMenuButton
: null,
suffixIcon: chatStore.settings.emoteMenuButtonOnLeft
? null
: emoteMenuButton,
hintMaxLines: 1,
hintText: chatStore.auth.isLoggedIn
? 'Send a message ${chatStore.settings.chatDelay == 0 ? '' : '(${chatStore.settings.chatDelay.toInt()}s delay)'}'
@ -146,12 +164,15 @@ class ChatBottomBar extends StatelessWidget {
if (chatStore.showSendButton &&
(chatStore.settings.chatWidth >= 0.3 ||
chatStore.expandChat ||
MediaQuery.of(context).orientation == Orientation.portrait))
MediaQuery.of(context).orientation ==
Orientation.portrait))
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send_rounded),
onPressed:
chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null,
onPressed: chatStore.auth.isLoggedIn
? () => chatStore
.sendMessage(chatStore.textController.text)
: null,
)
else
IconButton(

View file

@ -25,7 +25,8 @@ class ChatMessage extends StatelessWidget {
if (isModal) return;
// Ignore if long-pressing own username.
if (ircMessage.user == null || ircMessage.user == chatStore.auth.user.details?.login) return;
if (ircMessage.user == null ||
ircMessage.user == chatStore.auth.user.details?.login) return;
showModalBottomSheet(
backgroundColor: Colors.transparent,
@ -60,7 +61,8 @@ class ChatMessage extends StatelessWidget {
case Command.userState:
// If user is being mentioned in the message, highlight it red.
if (ircMessage.mention == true) color = Colors.red.withOpacity(0.2);
if (chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1') {
if (chatStore.settings.highlightFirstTimeChatter &&
ircMessage.tags['first-msg'] == '1') {
color = Colors.green.withOpacity(0.2);
}
@ -73,7 +75,8 @@ class ChatMessage extends StatelessWidget {
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
useReadableColors: chatStore.settings.useReadableColors,
isLightTheme: Theme.of(context).brightness == Brightness.light,
isLightTheme:
Theme.of(context).brightness == Brightness.light,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
),
@ -85,14 +88,17 @@ class ChatMessage extends StatelessWidget {
final replyBody = ircMessage.tags['reply-parent-msg-body'];
if ((replyUser != null && replyBody != null) ||
(chatStore.settings.highlightFirstTimeChatter && ircMessage.tags['first-msg'] == '1')) {
(chatStore.settings.highlightFirstTimeChatter &&
ircMessage.tags['first-msg'] == '1')) {
renderMessage = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
replyUser != null && replyBody != null ? Icons.reply_rounded : Icons.new_releases_outlined,
replyUser != null && replyBody != null
? Icons.reply_rounded
: Icons.new_releases_outlined,
size: defaultBadgeSize * chatStore.settings.badgeScale,
color: defaultTextStyle.color?.withOpacity(0.5),
textDirection: TextDirection.rtl,
@ -110,7 +116,8 @@ class ChatMessage extends StatelessWidget {
maxLines: 1,
style: TextStyle(
overflow: TextOverflow.ellipsis,
color: defaultTextStyle.color?.withOpacity(0.5),
color: defaultTextStyle.color
?.withOpacity(0.5),
),
),
)
@ -118,7 +125,8 @@ class ChatMessage extends StatelessWidget {
'First time chatting',
style: TextStyle(
fontWeight: FontWeight.w600,
color: defaultTextStyle.color?.withOpacity(0.5),
color:
defaultTextStyle.color?.withOpacity(0.5),
),
),
),
@ -170,7 +178,8 @@ class ChatMessage extends StatelessWidget {
badgeScale: chatStore.settings.badgeScale,
showMessage: chatStore.settings.showDeletedMessages,
useReadableColors: chatStore.settings.useReadableColors,
isLightTheme: Theme.of(context).brightness == Brightness.light,
isLightTheme:
Theme.of(context).brightness == Brightness.light,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
),
@ -196,14 +205,17 @@ class ChatMessage extends StatelessWidget {
if (ircMessage.tags.containsKey('system-msg'))
Text(
ircMessage.tags['system-msg']!,
style: TextStyle(fontWeight: FontWeight.w600, color: defaultTextStyle.color?.withOpacity(0.5)),
style: TextStyle(
fontWeight: FontWeight.w600,
color: defaultTextStyle.color?.withOpacity(0.5)),
),
if (ircMessage.tags['msg-id'] == 'announcement')
Row(
children: [
Icon(
Icons.announcement_outlined,
size: defaultBadgeSize * chatStore.settings.badgeScale,
size:
defaultBadgeSize * chatStore.settings.badgeScale,
),
const SizedBox(width: 5.0),
const Text(
@ -222,8 +234,10 @@ class ChatMessage extends StatelessWidget {
assetsStore: chatStore.assetsStore,
emoteScale: chatStore.settings.emoteScale,
badgeScale: chatStore.settings.badgeScale,
useReadableColors: chatStore.settings.useReadableColors,
isLightTheme: Theme.of(context).brightness == Brightness.light,
useReadableColors:
chatStore.settings.useReadableColors,
isLightTheme:
Theme.of(context).brightness == Brightness.light,
launchExternal: chatStore.settings.launchUrlExternal,
timestamp: chatStore.settings.timestampType,
),
@ -240,7 +254,9 @@ class ChatMessage extends StatelessWidget {
}
final paddedMessage = Padding(
padding: EdgeInsets.symmetric(vertical: chatStore.settings.messageSpacing / 2, horizontal: 10.0),
padding: EdgeInsets.symmetric(
vertical: chatStore.settings.messageSpacing / 2,
horizontal: 10.0),
child: renderMessage,
);
@ -259,12 +275,16 @@ class ChatMessage extends StatelessWidget {
: paddedMessage;
// Color the message if the color has been set.
final coloredMessage = color == null ? dividedMessage : ColoredBox(color: color, child: dividedMessage);
final coloredMessage = color == null
? dividedMessage
: ColoredBox(color: color, child: dividedMessage);
return InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false;
if (chatStore.assetsStore.showEmoteMenu) {
chatStore.assetsStore.showEmoteMenu = false;
}
},
onLongPress: onLongPressMessage,
child: coloredMessage,

View file

@ -31,8 +31,9 @@ class ChatUserModal extends StatefulWidget {
class _ChatUserModalState extends State<ChatUserModal> {
@override
Widget build(BuildContext context) {
final name =
regexEnglish.hasMatch(widget.displayName) ? widget.displayName : '${widget.displayName} (${widget.username})';
final name = regexEnglish.hasMatch(widget.displayName)
? widget.displayName
: '${widget.displayName} (${widget.username})';
return FrostyBottomSheet(
child: SizedBox(
@ -63,7 +64,8 @@ class _ChatUserModalState extends State<ChatUserModal> {
? Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
onPressed: () {
widget.chatStore.textController.text = '@${widget.username} ';
widget.chatStore.textController.text =
'@${widget.username} ';
Navigator.pop(context);
widget.chatStore.textFieldFocusNode.requestFocus();
},
@ -88,17 +90,21 @@ class _ChatUserModalState extends State<ChatUserModal> {
Expanded(
child: Observer(
builder: (context) {
final userMessages =
widget.chatStore.messages.reversed.where((message) => message.user == widget.username).toList();
final userMessages = widget.chatStore.messages.reversed
.where((message) => message.user == widget.username)
.toList();
if (userMessages.isEmpty) {
return const AlertMessage(message: 'No recent messages');
}
return MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: widget.chatStore.settings.messageScale),
data: MediaQuery.of(context).copyWith(
textScaleFactor:
widget.chatStore.settings.messageScale),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: widget.chatStore.settings.fontSize),
style: DefaultTextStyle.of(context).style.copyWith(
fontSize: widget.chatStore.settings.fontSize),
child: ListView.builder(
reverse: true,
itemCount: userMessages.length,

View file

@ -26,8 +26,11 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
}
@override
void didChangeAppLifecycleState(AppLifecycleState lifecycleState) async {
if (Platform.isAndroid && !await SimplePip.isAutoPipAvailable && lifecycleState == AppLifecycleState.inactive) {
Future<void> didChangeAppLifecycleState(
AppLifecycleState lifecycleState) async {
if (Platform.isAndroid &&
!await SimplePip.isAutoPipAvailable &&
lifecycleState == AppLifecycleState.inactive) {
widget.videoStore.requestPictureInPicture();
}
}
@ -40,7 +43,8 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
javascriptMode: JavascriptMode.unrestricted,
allowsInlineMediaPlayback: true,
initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow,
onWebViewCreated: (controller) => widget.videoStore.controller = controller,
onWebViewCreated: (controller) =>
widget.videoStore.controller = controller,
onPageFinished: (string) => widget.videoStore.initVideo(),
navigationDelegate: widget.videoStore.handleNavigation,
javascriptChannels: widget.videoStore.javascriptChannels,

View file

@ -25,7 +25,8 @@ class VideoBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final streamTitle = streamInfo.title.trim();
final category = streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category';
final category =
streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category';
final streamerName = regexEnglish.hasMatch(streamInfo.userName)
? streamInfo.userName
@ -64,7 +65,11 @@ class VideoBar extends StatelessWidget {
streamTitle,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: subtitleTextColor ?? Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
color: subtitleTextColor ??
Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
fontWeight: subtitleTextWeight,
),
),
@ -84,13 +89,18 @@ class VideoBar extends StatelessWidget {
)
: null,
child: Tooltip(
message: 'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
message:
'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
showDuration: const Duration(seconds: 3),
child: Text(
category,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: subtitleTextColor ?? Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
color: subtitleTextColor ??
Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
fontWeight: subtitleTextWeight,
),
),

View file

@ -35,9 +35,11 @@ class VideoOverlay extends StatelessWidget {
final chatOverlayButton = Observer(
builder: (_) => IconButton(
tooltip: videoStore.settingsStore.fullScreenChatOverlay ? 'Hide chat overlay' : 'Show chat overlay',
onPressed: () =>
videoStore.settingsStore.fullScreenChatOverlay = !videoStore.settingsStore.fullScreenChatOverlay,
tooltip: videoStore.settingsStore.fullScreenChatOverlay
? 'Hide chat overlay'
: 'Show chat overlay',
onPressed: () => videoStore.settingsStore.fullScreenChatOverlay =
!videoStore.settingsStore.fullScreenChatOverlay,
icon: videoStore.settingsStore.fullScreenChatOverlay
? const Icon(Icons.chat_rounded)
: const Icon(Icons.chat_outlined),
@ -58,19 +60,26 @@ class VideoOverlay extends StatelessWidget {
);
final fullScreenButton = Tooltip(
message: videoStore.settingsStore.fullScreen ? 'Exit fullscreen mode' : 'Enter fullscreen mode',
message: videoStore.settingsStore.fullScreen
? 'Exit fullscreen mode'
: 'Enter fullscreen mode',
preferBelow: false,
child: IconButton(
icon: Icon(
videoStore.settingsStore.fullScreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
videoStore.settingsStore.fullScreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
color: Colors.white,
),
onPressed: () => videoStore.settingsStore.fullScreen = !videoStore.settingsStore.fullScreen,
onPressed: () => videoStore.settingsStore.fullScreen =
!videoStore.settingsStore.fullScreen,
),
);
final rotateButton = Tooltip(
message: orientation == Orientation.portrait ? 'Enter landscape mode' : 'Exit landscape mode',
message: orientation == Orientation.portrait
? 'Enter landscape mode'
: 'Exit landscape mode',
preferBelow: false,
child: IconButton(
icon: const Icon(
@ -84,7 +93,8 @@ class VideoOverlay extends StatelessWidget {
DeviceOrientation.landscapeRight,
]);
} else {
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp]);
SystemChrome.setPreferredOrientations([]);
}
},
@ -100,7 +110,9 @@ class VideoOverlay extends StatelessWidget {
children: [
backButton,
const Spacer(),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
if (videoStore.settingsStore.fullScreen &&
orientation == Orientation.landscape)
chatOverlayButton,
],
),
Align(
@ -135,7 +147,9 @@ class VideoOverlay extends StatelessWidget {
subtitleTextWeight: FontWeight.w500,
),
),
if (videoStore.settingsStore.fullScreen && orientation == Orientation.landscape) chatOverlayButton,
if (videoStore.settingsStore.fullScreen &&
orientation == Orientation.landscape)
chatOverlayButton,
],
),
Center(
@ -145,7 +159,9 @@ class VideoOverlay extends StatelessWidget {
child: IconButton(
iconSize: 50.0,
icon: Icon(
videoStore.paused ? Icons.play_arrow_rounded : Icons.pause_rounded,
videoStore.paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
color: Colors.white,
),
onPressed: videoStore.handlePausePlay,
@ -197,11 +213,13 @@ class VideoOverlay extends StatelessWidget {
context: context,
builder: (context) => FrostyBottomSheet(
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
height: MediaQuery.of(context).size.height *
0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
chatDetailsStore: chatStore.chatDetailsStore,
chatDetailsStore:
chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: streamInfo.userLogin,
),
@ -218,7 +236,8 @@ class VideoOverlay extends StatelessWidget {
),
const SizedBox(width: 5),
Text(
NumberFormat().format(videoStore.streamInfo?.viewerCount),
NumberFormat().format(
videoStore.streamInfo?.viewerCount),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,

View file

@ -52,8 +52,10 @@ abstract class VideoStoreBase with Store {
onMessageReceived: (message) {
_paused = false;
if (Platform.isAndroid) pip.setIsPlaying(true);
controller?.runJavascript('document.getElementsByTagName("video")[0].muted = false;');
controller?.runJavascript('document.getElementsByTagName("video")[0].volume = 1.0;');
controller?.runJavascript(
'document.getElementsByTagName("video")[0].muted = false;');
controller?.runJavascript(
'document.getElementsByTagName("video")[0].volume = 1.0;');
},
),
};
@ -104,7 +106,8 @@ abstract class VideoStoreBase with Store {
}
// Initialize the [_overlayTimer] to hide the overlay automatically after 5 seconds.
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
_overlayTimer =
Timer(const Duration(seconds: 5), () => _overlayVisible = false);
// Initialize a reaction that will reload the webview whenever the overlay is toggled.
_disposeOverlayReaction = reaction(
@ -153,7 +156,8 @@ abstract class VideoStoreBase with Store {
updateStreamInfo();
_overlayVisible = true;
_overlayTimer = Timer(const Duration(seconds: 5), () => _overlayVisible = false);
_overlayTimer =
Timer(const Duration(seconds: 5), () => _overlayVisible = false);
}
}
@ -163,7 +167,8 @@ abstract class VideoStoreBase with Store {
@action
Future<void> updateStreamInfo() async {
try {
_streamInfo = await twitchApi.getStream(userLogin: userLogin, headers: authStore.headersTwitch);
_streamInfo = await twitchApi.getStream(
userLogin: userLogin, headers: authStore.headersTwitch);
} catch (e) {
debugPrint(e.toString());
@ -187,7 +192,8 @@ abstract class VideoStoreBase with Store {
_overlayVisible = true;
_overlayTimer.cancel();
_overlayTimer = Timer(const Duration(seconds: 3), () => _overlayVisible = false);
_overlayTimer =
Timer(const Duration(seconds: 3), () => _overlayVisible = false);
}
}
}
@ -204,9 +210,11 @@ abstract class VideoStoreBase with Store {
void handlePausePlay() {
try {
if (_paused) {
controller?.runJavascript('document.getElementsByTagName("video")[0].play();');
controller?.runJavascript(
'document.getElementsByTagName("video")[0].play();');
} else {
controller?.runJavascript('document.getElementsByTagName("video")[0].pause();');
controller?.runJavascript(
'document.getElementsByTagName("video")[0].pause();');
}
} catch (e) {
debugPrint(e.toString());
@ -222,7 +230,8 @@ abstract class VideoStoreBase with Store {
if (Platform.isAndroid) {
pip.enterPipMode(autoEnter: true);
} else if (Platform.isIOS) {
controller?.runJavascript('document.getElementsByTagName("video")[0].requestPictureInPicture();');
controller?.runJavascript(
'document.getElementsByTagName("video")[0].requestPictureInPicture();');
}
} catch (e) {
debugPrint(e.toString());

View file

@ -50,7 +50,8 @@ class _HomeState extends State<Home> {
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => Settings(settingsStore: context.read<SettingsStore>()),
builder: (context) =>
Settings(settingsStore: context.read<SettingsStore>()),
),
),
)
@ -78,7 +79,6 @@ class _HomeState extends State<Home> {
),
bottomNavigationBar: Observer(
builder: (_) => BottomNavigationBar(
unselectedFontSize: 12.0,
selectedFontSize: 12.0,
type: BottomNavigationBarType.fixed,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),

View file

@ -58,7 +58,8 @@ abstract class HomeStoreBase with Store {
duration: duration,
curve: Curves.easeOut,
);
} else if (index == 1 && topSectionScrollControllers[topSectionCurrentIndex].hasClients) {
} else if (index == 1 &&
topSectionScrollControllers[topSectionCurrentIndex].hasClients) {
// If on the top section, scroll to the top of the tab based on the current top tab.
topSectionScrollControllers[topSectionCurrentIndex].animateTo(
0.0,
@ -74,7 +75,8 @@ abstract class HomeStoreBase with Store {
);
}
} else {
if (index == 0 && topSectionScrollControllers[topSectionCurrentIndex].hasClients) {
if (index == 0 &&
topSectionScrollControllers[topSectionCurrentIndex].hasClients) {
// If on the top section, scroll to the top of the tab based on the current top tab.
topSectionScrollControllers[topSectionCurrentIndex].animateTo(
0.0,

View file

@ -49,12 +49,17 @@ class _SearchState extends State<Search> {
contentPadding: EdgeInsets.zero,
prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Find a channel or category',
suffixIcon: _searchStore.textFieldFocusNode.hasFocus || _searchStore.searchText.isNotEmpty
suffixIcon: _searchStore.textFieldFocusNode.hasFocus ||
_searchStore.searchText.isNotEmpty
? IconButton(
icon: const Icon(Icons.close_rounded),
tooltip: _searchStore.searchText.isEmpty ? 'Cancel' : 'Clear',
tooltip: _searchStore.searchText.isEmpty
? 'Cancel'
: 'Clear',
onPressed: () {
if (_searchStore.searchText.isEmpty) _searchStore.textFieldFocusNode.unfocus();
if (_searchStore.searchText.isEmpty) {
_searchStore.textFieldFocusNode.unfocus();
}
_searchStore.textEditingController.clear();
},
)
@ -93,14 +98,20 @@ class _SearchState extends State<Search> {
preferBelow: false,
child: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => _searchStore.searchHistory.removeAt(index),
onPressed: () => _searchStore.searchHistory
.removeAt(index),
),
),
onTap: () {
_searchStore.textEditingController.text = searchTerm;
_searchStore.textEditingController.text =
searchTerm;
_searchStore.handleQuery(searchTerm);
_searchStore.textEditingController.selection = TextSelection.fromPosition(
TextPosition(offset: _searchStore.textEditingController.text.length));
_searchStore.textEditingController.selection =
TextSelection.fromPosition(TextPosition(
offset: _searchStore
.textEditingController
.text
.length));
},
),
)

View file

@ -10,7 +10,8 @@ import 'package:mobx/mobx.dart';
class SearchResultsCategories extends StatelessWidget {
final SearchStore searchStore;
const SearchResultsCategories({Key? key, required this.searchStore}) : super(key: key);
const SearchResultsCategories({Key? key, required this.searchStore})
: super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -75,17 +75,18 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
),
);
case FutureStatus.fulfilled:
final results = (future.result as List<ChannelQuery>).where((channel) => !widget
.searchStore.authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId)
.contains(channel.id));
final results = (future.result as List<ChannelQuery>).where(
(channel) => !widget.searchStore.authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId)
.contains(channel.id));
return SliverList(
delegate: SliverChildListDelegate.fixed(
[
...results.map(
(channel) {
final displayName = regexEnglish.hasMatch(channel.displayName)
final displayName = regexEnglish
.hasMatch(channel.displayName)
? channel.displayName
: '${channel.displayName} (${channel.broadcasterLogin})';
@ -117,7 +118,8 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
child: FrostyListTile(
isThreeLine: false,
title: displayName,
leading: ProfilePicture(userLogin: channel.broadcasterLogin),
leading: ProfilePicture(
userLogin: channel.broadcasterLogin),
subtitle: channel.isLive
? Row(
children: [

View file

@ -41,7 +41,8 @@ abstract class SearchStoreBase with Store {
final prefs = await SharedPreferences.getInstance();
// Retrieve the search history from local storage. If it doesn't exist, use an empty list.
_searchHistory = prefs.getStringList('search_history')?.asObservable() ?? ObservableList<String>();
_searchHistory = prefs.getStringList('search_history')?.asObservable() ??
ObservableList<String>();
// Create a reaction that will limit the history to 8 entries and update it to local storage automatically.
autorun((_) {
@ -50,7 +51,8 @@ abstract class SearchStoreBase with Store {
});
// Add a listener to update the cancel button visibility whenever the text changes.
textEditingController.addListener(() => _searchText = textEditingController.text);
textEditingController
.addListener(() => _searchText = textEditingController.text);
}
/// Obtain the channels and categories that match the provided [query].
@ -87,8 +89,10 @@ abstract class SearchStoreBase with Store {
/// Find a specific channel provided the [query].
/// This is used for channels that may not show up in the search results.
Future<Channel> searchChannel(String query) async {
final user = await twitchApi.getUser(userLogin: query, headers: authStore.headersTwitch);
return await twitchApi.getChannel(userId: user.id, headers: authStore.headersTwitch);
final user = await twitchApi.getUser(
userLogin: query, headers: authStore.headersTwitch);
return await twitchApi.getChannel(
userId: user.id, headers: authStore.headersTwitch);
}
void dispose() {

View file

@ -33,7 +33,9 @@ class LargeStreamCard extends StatelessWidget {
// Get a new URL for the thumbnail every 5 minutes so that the image is updated on refresh.
// This method adds a random value to the end of the URL to override the cached image.
final time = DateTime.now();
final cacheUrlExtension = time.day.toString() + time.hour.toString() + (time.minute ~/ 5).toString();
final cacheUrlExtension = time.day.toString() +
time.hour.toString() +
(time.minute ~/ 5).toString();
// Calculate the width and height of the thumbnail based on the device width and the stream card size setting.
// Constraint the resolution to 1920x1080 since that's the max resolution of the Twitch API.
@ -62,10 +64,12 @@ class LargeStreamCard extends StatelessWidget {
child: AspectRatio(
aspectRatio: 16 / 9,
child: FrostyCachedNetworkImage(
imageUrl:
streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') +
cacheUrlExtension,
placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()),
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
'-{width}x{height}',
'-${thumbnailWidth}x$thumbnailHeight') +
cacheUrlExtension,
placeholder: (context, url) => const ColoredBox(
color: lightGray, child: LoadingIndicator()),
useOldImageOnUrlChange: true,
),
),
@ -157,7 +161,8 @@ class LargeStreamCard extends StatelessWidget {
);
},
child: Padding(
padding: EdgeInsets.symmetric(vertical: showThumbnail ? 10.0 : 5.0, horizontal: 15.0),
padding: EdgeInsets.symmetric(
vertical: showThumbnail ? 10.0 : 5.0, horizontal: 15.0),
child: Column(
children: [
if (showThumbnail) thumbnail,

View file

@ -35,7 +35,9 @@ class StreamCard extends StatelessWidget {
// Get a new URL for the thumbnail every 5 minutes so that the image is updated on refresh.
// This method adds a random value to the end of the URL to override the cached image.
final time = DateTime.now();
final cacheUrlExtension = time.day.toString() + time.hour.toString() + (time.minute ~/ 5).toString();
final cacheUrlExtension = time.day.toString() +
time.hour.toString() +
(time.minute ~/ 5).toString();
// Calculate the width and height of the thumbnail based on the device width and the stream card size setting.
// Constraint the resolution to 1920x1080 since that's the max resolution of the Twitch API.
@ -47,9 +49,11 @@ class StreamCard extends StatelessWidget {
final thumbnail = AspectRatio(
aspectRatio: 16 / 9,
child: FrostyCachedNetworkImage(
imageUrl: streamInfo.thumbnailUrl.replaceFirst('-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') +
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
'-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') +
cacheUrlExtension,
placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()),
placeholder: (context, url) =>
const ColoredBox(color: lightGray, child: LoadingIndicator()),
useOldImageOnUrlChange: true,
),
);
@ -147,10 +151,13 @@ class StreamCard extends StatelessWidget {
)
: null,
child: Tooltip(
message: 'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
message:
'Category: ${streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'None'}',
preferBelow: false,
child: Text(
streamInfo.gameName.isNotEmpty ? streamInfo.gameName : 'No Category',
streamInfo.gameName.isNotEmpty
? streamInfo.gameName
: 'No Category',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: subFontSize,
@ -198,12 +205,12 @@ class StreamCard extends StatelessWidget {
);
},
child: Padding(
padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: showThumbnail ? 15.0 : 5.0),
padding: EdgeInsets.symmetric(
vertical: 10.0, horizontal: showThumbnail ? 15.0 : 5.0),
child: Row(
children: [
if (showThumbnail)
Flexible(
flex: 1,
child: imageSection,
),
Flexible(

View file

@ -52,8 +52,9 @@ abstract class ListStoreBase with Store {
/// The list of the fetched streams with blocked users filtered out.
@computed
ObservableList<StreamTwitch> get streams => _allStreams
.where((streamInfo) =>
!authStore.user.blockedUsers.map((blockedUser) => blockedUser.userId).contains(streamInfo.userId))
.where((streamInfo) => !authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId)
.contains(streamInfo.userId))
.toList()
.asObservable();
@ -70,7 +71,8 @@ abstract class ListStoreBase with Store {
}) {
if (scrollController != null) {
scrollController!.addListener(() {
if (scrollController!.position.atEdge || scrollController!.position.outOfRange) {
if (scrollController!.position.atEdge ||
scrollController!.position.outOfRange) {
showJumpButton = false;
} else {
showJumpButton = true;

View file

@ -30,7 +30,8 @@ class StreamsList extends StatefulWidget {
State<StreamsList> createState() => _StreamsListState();
}
class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
class _StreamsListState extends State<StreamsList>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late final _listStore = ListStore(
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
@ -50,7 +51,9 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) _listStore.checkLastTimeRefreshedAndUpdate();
if (state == AppLifecycleState.resumed) {
_listStore.checkLastTimeRefreshedAndUpdate();
}
}
@override
@ -85,10 +88,13 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
if (_listStore.streams.isEmpty) {
if (_listStore.isLoading && _listStore.error == null) {
statusWidget = const LoadingIndicator(subtitle: 'Loading streams...');
statusWidget =
const LoadingIndicator(subtitle: 'Loading streams...');
} else {
statusWidget = AlertMessage(
message: widget.listType == ListType.followed ? 'No followed streams' : 'No top streams');
message: widget.listType == ListType.followed
? 'No followed streams'
: 'No top streams');
}
}
@ -109,23 +115,27 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
controller: widget.scrollController,
itemCount: _listStore.streams.length,
itemBuilder: (context, index) {
if (index > _listStore.streams.length - 10 && _listStore.hasMore) {
if (index > _listStore.streams.length - 10 &&
_listStore.hasMore) {
debugPrint('$index ${_listStore.streams.length}');
_listStore.getStreams();
}
return Observer(
builder: (context) => context.read<SettingsStore>().largeStreamCard
? LargeStreamCard(
key: ValueKey(_listStore.streams[index].userId),
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
)
: StreamCard(
key: ValueKey(_listStore.streams[index].userId),
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
),
builder: (context) =>
context.read<SettingsStore>().largeStreamCard
? LargeStreamCard(
key: ValueKey(_listStore.streams[index].userId),
streamInfo: _listStore.streams[index],
showThumbnail:
context.read<SettingsStore>().showThumbnails,
)
: StreamCard(
key: ValueKey(_listStore.streams[index].userId),
streamInfo: _listStore.streams[index],
showThumbnail:
context.read<SettingsStore>().showThumbnails,
),
);
},
);

View file

@ -21,7 +21,8 @@ class Categories extends StatefulWidget {
State<Categories> createState() => _CategoriesState();
}
class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
class _CategoriesState extends State<Categories>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late final _categoriesStore = CategoriesStore(
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
@ -40,7 +41,9 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) _categoriesStore.checkLastTimeRefreshedAndUpdate();
if (state == AppLifecycleState.resumed) {
_categoriesStore.checkLastTimeRefreshedAndUpdate();
}
}
@override
@ -74,7 +77,8 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
if (_categoriesStore.categories.isEmpty) {
if (_categoriesStore.isLoading && _categoriesStore.error == null) {
statusWidget = const LoadingIndicator(subtitle: 'Loading categories...');
statusWidget =
const LoadingIndicator(subtitle: 'Loading categories...');
} else {
statusWidget = const AlertMessage(message: 'No top categories');
}
@ -100,7 +104,8 @@ class _CategoriesState extends State<Categories> with AutomaticKeepAliveClientMi
),
itemCount: _categoriesStore.categories.length,
itemBuilder: (context, index) {
if (index > _categoriesStore.categories.length - 10 && _categoriesStore.hasMore) {
if (index > _categoriesStore.categories.length - 10 &&
_categoriesStore.hasMore) {
_categoriesStore.getCategories();
}
return CategoryCard(

View file

@ -42,9 +42,12 @@ class CategoryCard extends StatelessWidget {
child: AspectRatio(
aspectRatio: 3 / 4,
child: FrostyCachedNetworkImage(
imageUrl: category.boxArtUrl
.replaceRange(category.boxArtUrl.lastIndexOf('-') + 1, null, '${artWidth}x$artHeight.jpg'),
placeholder: (context, url) => const ColoredBox(color: lightGray, child: LoadingIndicator()),
imageUrl: category.boxArtUrl.replaceRange(
category.boxArtUrl.lastIndexOf('-') + 1,
null,
'${artWidth}x$artHeight.jpg'),
placeholder: (context, url) => const ColoredBox(
color: lightGray, child: LoadingIndicator()),
),
),
),

View file

@ -73,7 +73,8 @@ class _CategoryStreamsState extends State<CategoryStreams> {
alignment: AlignmentDirectional.bottomCenter,
children: [
CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
controller: _listStore.scrollController,
slivers: [
SliverAppBar(
@ -92,26 +93,38 @@ class _CategoryStreamsState extends State<CategoryStreams> {
centerTitle: true,
title: Text(
widget.categoryName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
background: FutureBuilder(
future: context.read<TwitchApi>().getCategory(
headers: context.read<AuthStore>().headersTwitch,
headers:
context.read<AuthStore>().headersTwitch,
gameId: _listStore.categoryId!,
),
builder: (context, AsyncSnapshot<CategoriesTwitch> snapshot) {
builder: (context,
AsyncSnapshot<CategoriesTwitch> snapshot) {
return snapshot.hasData
? FrostyCachedNetworkImage(
imageUrl: snapshot.data!.data.first.boxArtUrl.replaceRange(
snapshot.data!.data.first.boxArtUrl.lastIndexOf('-') + 1,
imageUrl: snapshot
.data!.data.first.boxArtUrl
.replaceRange(
snapshot.data!.data.first.boxArtUrl
.lastIndexOf('-') +
1,
null,
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) =>
const ColoredBox(color: lightGray, child: LoadingIndicator()),
color: const Color.fromRGBO(255, 255, 255, 0.5),
const ColoredBox(
color: lightGray,
child: LoadingIndicator()),
color: const Color.fromRGBO(
255, 255, 255, 0.5),
colorBlendMode: BlendMode.modulate,
fit: BoxFit.cover,
)
@ -146,19 +159,26 @@ class _CategoryStreamsState extends State<CategoryStreams> {
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index > _listStore.streams.length - 10 && _listStore.hasMore) {
if (index > _listStore.streams.length - 10 &&
_listStore.hasMore) {
_listStore.getStreams();
}
return Observer(
builder: (context) => context.read<SettingsStore>().largeStreamCard
builder: (context) => context
.read<SettingsStore>()
.largeStreamCard
? LargeStreamCard(
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory: false,
)
: StreamCard(
streamInfo: _listStore.streams[index],
showThumbnail: context.read<SettingsStore>().showThumbnails,
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory: false,
),
);
@ -176,7 +196,8 @@ class _CategoryStreamsState extends State<CategoryStreams> {
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _listStore.showJumpButton
? ScrollToTopButton(scrollController: _listStore.scrollController!)
? ScrollToTopButton(
scrollController: _listStore.scrollController!)
: null,
),
),

View file

@ -17,13 +17,15 @@ class TopSection extends StatefulWidget {
State<TopSection> createState() => _TopSectionState();
}
class _TopSectionState extends State<TopSection> with SingleTickerProviderStateMixin {
class _TopSectionState extends State<TopSection>
with SingleTickerProviderStateMixin {
late final _tabBarController = TabController(length: 2, vsync: this);
@override
void initState() {
super.initState();
_tabBarController.addListener(() => widget.homeStore.topSectionCurrentIndex = _tabBarController.index);
_tabBarController.addListener(() =>
widget.homeStore.topSectionCurrentIndex = _tabBarController.index);
}
@override
@ -50,10 +52,12 @@ class _TopSectionState extends State<TopSection> with SingleTickerProviderStateM
children: [
StreamsList(
listType: ListType.top,
scrollController: widget.homeStore.topSectionScrollControllers[0],
scrollController:
widget.homeStore.topSectionScrollControllers[0],
),
Categories(
scrollController: widget.homeStore.topSectionScrollControllers[1],
scrollController:
widget.homeStore.topSectionScrollControllers[1],
),
],
),

View file

@ -13,9 +13,12 @@ class OnboardingIntro extends StatelessWidget {
builder: (context, snapshot) {
return OnboardingScaffold(
header: 'Frosty',
subtitle: 'A mobile Twitch client with 7TV, BetterTTV (BTTV), and FrankerFaceZ (FFZ) support.',
subtitle:
'A mobile Twitch client with 7TV, BetterTTV (BTTV), and FrankerFaceZ (FFZ) support.',
showLogo: true,
disclaimer: snapshot.hasData ? 'v${snapshot.data?.version} (${snapshot.data?.buildNumber})' : null,
disclaimer: snapshot.hasData
? 'v${snapshot.data?.version} (${snapshot.data?.buildNumber})'
: null,
route: const OnboardingLogin(),
);
},

View file

@ -16,7 +16,8 @@ class OnboardingLogin extends StatelessWidget {
return OnboardingScaffold(
header: 'Log in',
subtitle: 'Frosty needs your permission in order to enable the ability to chat, view followed streams, and more.',
subtitle:
'Frosty needs your permission in order to enable the ability to chat, view followed streams, and more.',
disclaimer:
'Frosty only asks for the necessary permissions through the official Twitch API. You\'ll be able to review them before authorizing.',
buttonText: 'Connect with Twitch',
@ -28,8 +29,8 @@ class OnboardingLogin extends StatelessWidget {
),
body: WebView(
initialUrl: authStore.loginUri.toString(),
navigationDelegate: (navigation) =>
authStore.handleNavigation(navigation: navigation, routeAfter: const OnboardingSetup()),
navigationDelegate: (navigation) => authStore.handleNavigation(
navigation: navigation, routeAfter: const OnboardingSetup()),
javascriptMode: JavascriptMode.unrestricted,
),
),

View file

@ -37,7 +37,10 @@ class OnboardingScaffold extends StatelessWidget {
DeviceOrientation.portraitDown,
]);
if (isLast) SharedPreferences.getInstance().then((prefs) => prefs.setBool('first_run', false));
if (isLast) {
SharedPreferences.getInstance()
.then((prefs) => prefs.setBool('first_run', false));
}
return Scaffold(
appBar: AppBar(),
@ -85,13 +88,15 @@ class OnboardingScaffold extends StatelessWidget {
),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(vertical: content == null ? 20.0 : 0.0),
padding: EdgeInsets.symmetric(
vertical: content == null ? 20.0 : 0.0),
child: content ?? const SizedBox(),
),
),
if (disclaimer != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5.0),
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 5.0),
child: Opacity(
opacity: 0.5,
child: Text(

View file

@ -18,7 +18,8 @@ class OnboardingSetup extends StatelessWidget {
return OnboardingScaffold(
header: 'Setup',
subtitle: 'Let\'s tweak some settings before you get started. You can always change these later.',
subtitle:
'Let\'s tweak some settings before you get started. You can always change these later.',
content: Observer(
builder: (context) {
return ListView(
@ -27,15 +28,18 @@ class OnboardingSetup extends StatelessWidget {
title: 'Theme',
selectedOption: themeNames[settingsStore.themeType.index],
options: themeNames,
onChanged: (newTheme) => settingsStore.themeType = ThemeType.values[themeNames.indexOf(newTheme)],
onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)],
),
SettingsListSwitch(
title: 'Send anonymous crash logs',
subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.io.'),
subtitle: const Text(
'Help improve Frosty by sending anonymous crash logs through Sentry.io.'),
value: settingsStore.sendCrashLogs,
onChanged: (newValue) {
if (newValue == true) {
SentryFlutter.init((options) => options.tracesSampleRate = sampleRate);
SentryFlutter.init(
(options) => options.tracesSampleRate = sampleRate);
} else {
Sentry.close();
}

View file

@ -21,7 +21,8 @@ class BlockedUsers extends StatelessWidget {
onRefresh: () async {
HapticFeedback.lightImpact();
await authStore.user.refreshBlockedUsers(headers: authStore.headersTwitch);
await authStore.user
.refreshBlockedUsers(headers: authStore.headersTwitch);
},
child: Observer(
builder: (context) {
@ -35,7 +36,8 @@ class BlockedUsers extends StatelessWidget {
return ListView(
children: authStore.user.blockedUsers.map(
(blockedUser) {
final displayName = regexEnglish.hasMatch(blockedUser.displayName)
final displayName = regexEnglish
.hasMatch(blockedUser.displayName)
? blockedUser.displayName
: '${blockedUser.displayName} (${blockedUser.userLogin})';

View file

@ -24,7 +24,8 @@ class ProfileCard extends StatelessWidget {
content: Column(
children: [
Button(
padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0),
padding:
const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
@ -35,7 +36,8 @@ class ProfileCard extends StatelessWidget {
),
body: WebView(
initialUrl: authStore.loginUri.toString(),
navigationDelegate: (navigation) => authStore.handleNavigation(navigation: navigation),
navigationDelegate: (navigation) =>
authStore.handleNavigation(navigation: navigation),
javascriptMode: JavascriptMode.unrestricted,
),
);
@ -108,7 +110,8 @@ class ProfileCard extends StatelessWidget {
return FrostyListTile(
leading: const Icon(Icons.no_accounts_rounded),
title: 'Anonymous',
subtitle: const Text('Tap to log in and enable the ability to chat, view followed streams, and more.'),
subtitle: const Text(
'Tap to log in and enable the ability to chat, view followed streams, and more.'),
trailing: const SizedBox(
height: double.infinity,
child: Icon(Icons.chevron_right_rounded),

View file

@ -31,13 +31,16 @@ class _ChatSettingsState extends State<ChatSettings> {
children: [
const SectionHeader('Message sizing'),
ExpansionTile(
title: const Text('Preview', style: TextStyle(fontWeight: FontWeight.w500)),
title: const Text('Preview',
style: TextStyle(fontWeight: FontWeight.w500)),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: settingsStore.fontSize),
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: settingsStore.fontSize),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -49,17 +52,22 @@ class _ChatSettingsState extends State<ChatSettings> {
child: FrostyCachedNetworkImage(
imageUrl:
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
height: defaultBadgeSize * settingsStore.badgeScale,
width: defaultBadgeSize * settingsStore.badgeScale,
height:
defaultBadgeSize * settingsStore.badgeScale,
width:
defaultBadgeSize * settingsStore.badgeScale,
),
),
const TextSpan(text: ' Badge and emote preview. '),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: FrostyCachedNetworkImage(
imageUrl: 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
height: defaultEmoteSize * settingsStore.emoteScale,
width: defaultEmoteSize * settingsStore.emoteScale,
imageUrl:
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
height:
defaultEmoteSize * settingsStore.emoteScale,
width:
defaultEmoteSize * settingsStore.emoteScale,
),
),
],
@ -129,27 +137,31 @@ class _ChatSettingsState extends State<ChatSettings> {
const SectionHeader('Message appearance'),
SettingsListSwitch(
title: 'Use readable name colors',
subtitle: const Text('Adjusts the lightness value of overly bright and dark names.'),
subtitle: const Text(
'Adjusts the lightness value of overly bright and dark names.'),
value: settingsStore.useReadableColors,
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
),
SettingsListSwitch(
title: 'Show deleted messages',
subtitle: const Text('Restores the original message of deleted messages.'),
subtitle: const Text(
'Restores the original message of deleted messages.'),
value: settingsStore.showDeletedMessages,
onChanged: (newValue) => settingsStore.showDeletedMessages = newValue,
onChanged: (newValue) =>
settingsStore.showDeletedMessages = newValue,
),
SettingsListSwitch(
title: 'Show message dividers',
value: settingsStore.showChatMessageDividers,
onChanged: (newValue) => settingsStore.showChatMessageDividers = newValue,
onChanged: (newValue) =>
settingsStore.showChatMessageDividers = newValue,
),
SettingsListSelect(
title: 'Message timestamps',
selectedOption: timestampNames[settingsStore.timestampType.index],
options: timestampNames,
onChanged: (newValue) =>
settingsStore.timestampType = TimestampType.values[timestampNames.indexOf(newValue)],
onChanged: (newValue) => settingsStore.timestampType =
TimestampType.values[timestampNames.indexOf(newValue)],
),
const SectionHeader('Delay'),
SettingsListSlider(
@ -166,11 +178,13 @@ class _ChatSettingsState extends State<ChatSettings> {
SettingsListSwitch(
title: 'Highlight first time chatters',
value: settingsStore.highlightFirstTimeChatter,
onChanged: (newValue) => settingsStore.highlightFirstTimeChatter = newValue,
onChanged: (newValue) =>
settingsStore.highlightFirstTimeChatter = newValue,
),
SettingsListSwitch(
title: 'Show notices',
subtitle: const Text('Shows notices such as subs and re-subs, announcements, and raids.'),
subtitle: const Text(
'Shows notices such as subs and re-subs, announcements, and raids.'),
value: settingsStore.showUserNotices,
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
),
@ -182,34 +196,43 @@ class _ChatSettingsState extends State<ChatSettings> {
),
SettingsListSwitch(
title: 'Move emote menu button left',
subtitle: const Text('Places the emote menu button on the left side to avoid accidental presses.'),
subtitle: const Text(
'Places the emote menu button on the left side to avoid accidental presses.'),
value: settingsStore.emoteMenuButtonOnLeft,
onChanged: (newValue) => settingsStore.emoteMenuButtonOnLeft = newValue,
onChanged: (newValue) =>
settingsStore.emoteMenuButtonOnLeft = newValue,
),
SettingsListSwitch(
title: 'Move notifications to bottom',
value: settingsStore.chatNotificationsOnBottom,
onChanged: (newValue) => settingsStore.chatNotificationsOnBottom = newValue,
onChanged: (newValue) =>
settingsStore.chatNotificationsOnBottom = newValue,
),
const SectionHeader('Landscape mode'),
SettingsListSwitch(
title: 'Move chat left',
value: settingsStore.landscapeChatLeftSide,
onChanged: (newValue) => settingsStore.landscapeChatLeftSide = newValue,
onChanged: (newValue) =>
settingsStore.landscapeChatLeftSide = newValue,
),
SettingsListSwitch(
title: 'Force vertical chat',
subtitle: const Text('Intended for tablets and other larger displays.'),
subtitle:
const Text('Intended for tablets and other larger displays.'),
value: settingsStore.landscapeForceVerticalChat,
onChanged: (newValue) => settingsStore.landscapeForceVerticalChat = newValue,
onChanged: (newValue) =>
settingsStore.landscapeForceVerticalChat = newValue,
),
SettingsListSelect(
title: 'Fill notch side',
subtitle: 'Overrides and fills the available space in devices with a display notch.',
selectedOption: landscapeCutoutNames[settingsStore.landscapeCutout.index],
subtitle:
'Overrides and fills the available space in devices with a display notch.',
selectedOption:
landscapeCutoutNames[settingsStore.landscapeCutout.index],
options: landscapeCutoutNames,
onChanged: (newValue) =>
settingsStore.landscapeCutout = LandscapeCutoutType.values[landscapeCutoutNames.indexOf(newValue)],
onChanged: (newValue) => settingsStore.landscapeCutout =
LandscapeCutoutType
.values[landscapeCutoutNames.indexOf(newValue)],
),
SettingsListSlider(
title: 'Chat width',
@ -222,23 +245,29 @@ class _ChatSettingsState extends State<ChatSettings> {
),
SettingsListSlider(
title: 'Chat overlay opacity',
trailing: '${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
subtitle: 'Sets the opacity (transparency) of the overlay chat in fullscreen mode.',
trailing:
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
subtitle:
'Sets the opacity (transparency) of the overlay chat in fullscreen mode.',
value: settingsStore.fullScreenChatOverlayOpacity,
divisions: 10,
onChanged: (newValue) => settingsStore.fullScreenChatOverlayOpacity = newValue,
onChanged: (newValue) =>
settingsStore.fullScreenChatOverlayOpacity = newValue,
),
const SectionHeader('Sleep'),
SettingsListSwitch(
title: 'Prevent sleep in chat-only mode',
subtitle: const Text('Requires restarting the chat in order to take effect.'),
subtitle: const Text(
'Requires restarting the chat in order to take effect.'),
value: settingsStore.chatOnlyPreventSleep,
onChanged: (newValue) => settingsStore.chatOnlyPreventSleep = newValue,
onChanged: (newValue) =>
settingsStore.chatOnlyPreventSleep = newValue,
),
const SectionHeader('Autocomplete'),
SettingsListSwitch(
title: 'Show autocomplete bar',
subtitle: const Text('Shows a bar containing matching emotes and mentions while typing.'),
subtitle: const Text(
'Shows a bar containing matching emotes and mentions while typing.'),
value: settingsStore.autocomplete,
onChanged: (newValue) => settingsStore.autocomplete = newValue,
),

View file

@ -8,7 +8,8 @@ import 'package:frosty/widgets/section_header.dart';
class GeneralSettings extends StatelessWidget {
final SettingsStore settingsStore;
const GeneralSettings({Key? key, required this.settingsStore}) : super(key: key);
const GeneralSettings({Key? key, required this.settingsStore})
: super(key: key);
@override
Widget build(BuildContext context) {
@ -20,7 +21,8 @@ class GeneralSettings extends StatelessWidget {
title: 'Theme',
selectedOption: themeNames[settingsStore.themeType.index],
options: themeNames,
onChanged: (newTheme) => settingsStore.themeType = ThemeType.values[themeNames.indexOf(newTheme)],
onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)],
),
const SectionHeader('Stream card'),
SettingsListSwitch(

View file

@ -80,7 +80,8 @@ class _OtherSettingsState extends State<OtherSettings> {
height: 80,
),
applicationName: packageInfo.appName,
applicationVersion: 'Version ${packageInfo.version} (${packageInfo.buildNumber})',
applicationVersion:
'Version ${packageInfo.version} (${packageInfo.buildNumber})',
applicationLegalese: '\u{a9} 2023 Tommy Chow',
);
},
@ -88,14 +89,19 @@ class _OtherSettingsState extends State<OtherSettings> {
FrostyListTile(
leading: const Icon(Icons.launch_rounded),
title: 'Changelog',
onTap: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty/releases'),
mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
onTap: () => launchUrl(
Uri.parse('https://github.com/tommyxchow/frosty/releases'),
mode: widget.settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
),
FrostyListTile(
leading: const Icon(Icons.launch_rounded),
title: 'FAQ',
onTap: () => launchUrl(Uri.parse('https://www.frostyapp.io/#faq'),
mode: widget.settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
mode: widget.settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
),
FrostyListTile(
leading: const Icon(Icons.delete_outline_rounded),
@ -122,11 +128,13 @@ class _OtherSettingsState extends State<OtherSettings> {
Observer(
builder: (_) => SettingsListSwitch(
title: 'Send anonymous crash logs',
subtitle: const Text('Help improve Frosty by sending anonymous crash logs through Sentry.io.'),
subtitle: const Text(
'Help improve Frosty by sending anonymous crash logs through Sentry.io.'),
value: widget.settingsStore.sendCrashLogs,
onChanged: (newValue) {
if (newValue == true) {
SentryFlutter.init((options) => options.tracesSampleRate = sampleRate);
SentryFlutter.init(
(options) => options.tracesSampleRate = sampleRate);
} else {
Sentry.close();
}

View file

@ -29,14 +29,20 @@ class Settings extends StatelessWidget {
if (Platform.isAndroid)
IconButton(
tooltip: 'Support Frosty',
onPressed: () => launchUrl(Uri.parse('https://www.buymeacoffee.com/tommychow'),
mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
onPressed: () => launchUrl(
Uri.parse('https://www.buymeacoffee.com/tommychow'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
icon: const Icon(SimpleIcons.buymeacoffee),
),
IconButton(
tooltip: 'View source on GitHub',
onPressed: () => launchUrl(Uri.parse('https://github.com/tommyxchow/frosty'),
mode: settingsStore.launchUrlExternal ? LaunchMode.externalApplication : LaunchMode.inAppWebView),
onPressed: () => launchUrl(
Uri.parse('https://github.com/tommyxchow/frosty'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
icon: const Icon(SimpleIcons.github),
),
],

View file

@ -45,7 +45,8 @@ abstract class AuthBase with Store {
/// Authentication headers for Twitch API requests.
@computed
Map<String, String> get headersTwitch => {'Authorization': 'Bearer $_token', 'Client-Id': clientId};
Map<String, String> get headersTwitch =>
{'Authorization': 'Bearer $_token', 'Client-Id': clientId};
/// Error flag that will be non-null and contain an error message if login failed.
@readonly
@ -60,13 +61,15 @@ abstract class AuthBase with Store {
'client_id': clientId,
'redirect_uri': 'https://twitch.tv/login',
'response_type': 'token',
'scope': 'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users',
'scope':
'chat:read chat:edit user:read:follows user:read:blocked_users user:manage:blocked_users',
'force_verify': 'true',
},
);
/// Navigation handler for the login webview. Fires on every navigation request (whenever the URL changes).
FutureOr<NavigationDecision> handleNavigation({required NavigationRequest navigation, Widget? routeAfter}) {
FutureOr<NavigationDecision> handleNavigation(
{required NavigationRequest navigation, Widget? routeAfter}) {
// Check if the URL is the redirect URI.
if (navigation.url.startsWith('https://twitch.tv/login')) {
// Extract the token from the query parameters.
@ -83,7 +86,8 @@ abstract class AuthBase with Store {
if (navigation.url == 'https://www.twitch.tv/?no-reload=true') {
if (routeAfter != null) {
navigatorKey.currentState?.pop();
navigatorKey.currentState?.push(MaterialPageRoute(builder: (context) => routeAfter));
navigatorKey.currentState
?.push(MaterialPageRoute(builder: (context) => routeAfter));
} else {
// Pop twice, once to dismiss the WebView and again to dismiss the Login dialog.
navigatorKey.currentState?.pop();
@ -101,7 +105,9 @@ abstract class AuthBase with Store {
required String targetUser,
required String targetUserId,
}) {
final isBlocked = user.blockedUsers.where((blockedUser) => blockedUser.userId == targetUserId).isNotEmpty;
final isBlocked = user.blockedUsers
.where((blockedUser) => blockedUser.userId == targetUserId)
.isNotEmpty;
final title = isBlocked ? 'Unblock $targetUser' : 'Block $targetUser';

View file

@ -7,7 +7,8 @@ part 'settings_store.g.dart';
class SettingsStore extends _SettingsStoreBase with _$SettingsStore {
SettingsStore();
factory SettingsStore.fromJson(Map<String, dynamic> json) => _$SettingsStoreFromJson(json);
factory SettingsStore.fromJson(Map<String, dynamic> json) =>
_$SettingsStoreFromJson(json);
Map<String, dynamic> toJson() => _$SettingsStoreToJson(this);
}
@ -161,7 +162,9 @@ abstract class _SettingsStoreBase with Store {
@observable
var showChatMessageDividers = defaultShowChatMessageDividers;
@JsonKey(defaultValue: defaultTimestampType, unknownEnumValue: TimestampType.disabled)
@JsonKey(
defaultValue: defaultTimestampType,
unknownEnumValue: TimestampType.disabled)
@observable
var timestampType = defaultTimestampType;

View file

@ -34,7 +34,8 @@ abstract class UserStoreBase with Store {
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
}
_disposeReaction = autorun((_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)));
_disposeReaction = autorun((_) =>
_blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)));
}
@action
@ -43,20 +44,28 @@ abstract class UserStoreBase with Store {
required String displayName,
required Map<String, String> headers,
}) async {
final success = await twitchApi.blockUser(userId: targetId, headers: headers);
final success =
await twitchApi.blockUser(userId: targetId, headers: headers);
if (success) _blockedUsers.add(UserBlockedTwitch(targetId, displayName, displayName));
if (success) {
_blockedUsers.add(UserBlockedTwitch(targetId, displayName, displayName));
}
}
@action
Future<void> unblock({required String targetId, required Map<String, String> headers}) async {
final success = await twitchApi.unblockUser(userId: targetId, headers: headers);
Future<void> unblock(
{required String targetId, required Map<String, String> headers}) async {
final success =
await twitchApi.unblockUser(userId: targetId, headers: headers);
if (success) await refreshBlockedUsers(headers: headers);
}
@action
Future<void> refreshBlockedUsers({required Map<String, String> headers}) async =>
_blockedUsers = (await twitchApi.getUserBlockedList(id: _details!.id, headers: headers)).asObservable();
Future<void> refreshBlockedUsers(
{required Map<String, String> headers}) async =>
_blockedUsers = (await twitchApi.getUserBlockedList(
id: _details!.id, headers: headers))
.asObservable();
@action
void dispose() {

View file

@ -8,7 +8,8 @@ import 'package:frosty/widgets/section_header.dart';
class VideoSettings extends StatelessWidget {
final SettingsStore settingsStore;
const VideoSettings({Key? key, required this.settingsStore}) : super(key: key);
const VideoSettings({Key? key, required this.settingsStore})
: super(key: key);
@override
Widget build(BuildContext context) {
@ -24,20 +25,24 @@ class VideoSettings extends StatelessWidget {
const SectionHeader('Overlay'),
SettingsListSwitch(
title: 'Use custom video overlay',
subtitle: const Text('Replaces Twitch\'s default web overlay with a mobile-friendly version.'),
subtitle: const Text(
'Replaces Twitch\'s default web overlay with a mobile-friendly version.'),
value: settingsStore.showOverlay,
onChanged: (newValue) => settingsStore.showOverlay = newValue,
),
SettingsListSwitch(
title: 'Long-press player to toggle overlay',
subtitle: const Text('Allows switching between Twitch\'s overlay and the custom overlay.'),
subtitle: const Text(
'Allows switching between Twitch\'s overlay and the custom overlay.'),
value: settingsStore.toggleableOverlay,
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,
),
SettingsListSlider(
title: 'Custom overlay opacity',
trailing: '${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%',
subtitle: 'Adjusts the opacity (transparency) of the custom video overlay when active.',
trailing:
'${(settingsStore.overlayOpacity * 100).toStringAsFixed(0)}%',
subtitle:
'Adjusts the opacity (transparency) of the custom video overlay when active.',
value: settingsStore.overlayOpacity,
divisions: 10,
onChanged: (newValue) => settingsStore.overlayOpacity = newValue,

View file

@ -51,13 +51,16 @@ class SettingsListSelect extends StatelessWidget {
padding: const EdgeInsets.all(16.0),
child: Text(
'Select ${title.toLowerCase()}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18),
),
),
...options
.map((option) => ListTile(
title: Text(option),
trailing: selectedOption == option ? const Icon(Icons.check_rounded) : null,
trailing: selectedOption == option
? const Icon(Icons.check_rounded)
: null,
onTap: () {
onChanged(option);
Navigator.of(context).pop();

View file

@ -30,12 +30,15 @@ class SettingsListSlider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
contentPadding:
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
title: Row(
children: [
Text(title, style: _textStyle),
const Spacer(),
Text(trailing, style: _textStyle.copyWith(fontFeatures: [const FontFeature.tabularFigures()])),
Text(trailing,
style: _textStyle.copyWith(
fontFeatures: [const FontFeature.tabularFigures()])),
],
),
subtitle: Column(

View file

@ -17,7 +17,8 @@ class AnimateScale extends StatefulWidget {
State<AnimateScale> createState() => _AnimateScaleState();
}
class _AnimateScaleState extends State<AnimateScale> with SingleTickerProviderStateMixin {
class _AnimateScaleState extends State<AnimateScale>
with SingleTickerProviderStateMixin {
late final _animationController = AnimationController(
vsync: this,
upperBound: 0.05,

View file

@ -29,7 +29,8 @@ class BlockReportModal extends StatelessWidget {
FrostyListTile(
leading: const Icon(Icons.block_rounded),
onTap: () => authStore
.showBlockDialog(context, targetUser: name, targetUserId: userId)
.showBlockDialog(context,
targetUser: name, targetUserId: userId)
.then((_) => Navigator.pop(context)),
title: 'Block $name',
),

View file

@ -37,24 +37,29 @@ class _ButtonState extends State<Button> with SingleTickerProviderStateMixin {
Widget build(BuildContext context) {
final buttonStyle = ElevatedButton.styleFrom(
foregroundColor: widget.fill ? null : widget.color,
backgroundColor: widget.color == null || widget.fill ? widget.color : Colors.transparent,
backgroundColor: widget.color == null || widget.fill
? widget.color
: Colors.transparent,
padding: widget.padding,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
textStyle: DefaultTextStyle.of(context).style.copyWith(fontWeight: FontWeight.w600, fontSize: widget.fontSize),
textStyle: DefaultTextStyle.of(context)
.style
.copyWith(fontWeight: FontWeight.w600, fontSize: widget.fontSize),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
elevation: widget.color == null ? 10.0 : 0.0,
).copyWith(
elevation: (widget.color == null || widget.fill) && widget.onPressed != null
? MaterialStateProperty.resolveWith(
(states) {
if (states.contains(MaterialState.pressed)) {
return 0.0;
} else {
return 5.0;
}
},
)
: MaterialStateProperty.all(0.0),
elevation:
(widget.color == null || widget.fill) && widget.onPressed != null
? MaterialStateProperty.resolveWith(
(states) {
if (states.contains(MaterialState.pressed)) {
return 0.0;
} else {
return 5.0;
}
},
)
: MaterialStateProperty.all(0.0),
);
final button = widget.icon == null

View file

@ -36,8 +36,10 @@ class FrostyCachedNetworkImage extends StatelessWidget {
colorBlendMode: colorBlendMode,
placeholder: placeholder,
useOldImageOnUrlChange: useOldImageOnUrlChange,
fadeOutDuration: useFade ? const Duration(milliseconds: 500) : Duration.zero,
fadeInDuration: useFade ? const Duration(milliseconds: 500) : Duration.zero,
fadeOutDuration:
useFade ? const Duration(milliseconds: 500) : Duration.zero,
fadeInDuration:
useFade ? const Duration(milliseconds: 500) : Duration.zero,
fadeInCurve: Curves.easeOut,
fadeOutCurve: Curves.easeIn,
fit: fit,

View file

@ -4,7 +4,8 @@ class LoadingIndicator extends StatelessWidget {
final String? subtitle;
final double spacing;
const LoadingIndicator({Key? key, this.subtitle, this.spacing = 10.0}) : super(key: key);
const LoadingIndicator({Key? key, this.subtitle, this.spacing = 10.0})
: super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -20,7 +20,8 @@ class FrostyPageView extends StatefulWidget {
class _FrostyPageViewState extends State<FrostyPageView> {
var currentIndex = 0;
late final PageController _pageContoller = PageController(initialPage: currentIndex);
late final PageController _pageContoller =
PageController(initialPage: currentIndex);
@override
Widget build(BuildContext context) {
@ -39,13 +40,16 @@ class _FrostyPageViewState extends State<FrostyPageView> {
(index, section) => SizedBox(
height: 40,
child: Button(
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0),
padding: const EdgeInsets.symmetric(
horizontal: 10.0, vertical: 5.0),
onPressed: () => _pageContoller.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
),
color: index == currentIndex ? Theme.of(context).colorScheme.secondary : Colors.grey,
color: index == currentIndex
? Theme.of(context).colorScheme.secondary
: Colors.grey,
child: Text(
section,
style: const TextStyle(fontWeight: FontWeight.w600),

View file

@ -22,8 +22,9 @@ class ProfilePicture extends StatelessWidget {
return ClipOval(
child: FutureBuilder(
future:
context.read<TwitchApi>().getUser(userLogin: userLogin, headers: context.read<AuthStore>().headersTwitch),
future: context.read<TwitchApi>().getUser(
userLogin: userLogin,
headers: context.read<AuthStore>().headersTwitch),
builder: (context, AsyncSnapshot<UserTwitch> snapshot) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
@ -34,7 +35,8 @@ class ProfilePicture extends StatelessWidget {
width: diameter,
height: diameter,
imageUrl: snapshot.data!.profileImageUrl,
placeholder: (context, url) => const ColoredBox(color: lightGray),
placeholder: (context, url) =>
const ColoredBox(color: lightGray),
)
: Container(
color: lightGray,

View file

@ -4,7 +4,8 @@ import 'package:frosty/widgets/button.dart';
class ScrollToTopButton extends StatelessWidget {
final ScrollController scrollController;
const ScrollToTopButton({Key? key, required this.scrollController}) : super(key: key);
const ScrollToTopButton({Key? key, required this.scrollController})
: super(key: key);
@override
Widget build(BuildContext context) {

View file

@ -25,14 +25,19 @@ class _UptimeState extends State<Uptime> {
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) => setState(() {}));
_timer =
Timer.periodic(const Duration(seconds: 1), (timer) => setState(() {}));
}
@override
Widget build(BuildContext context) {
return Text(
DateTime.now().difference(DateTime.parse(widget.startTime)).toString().split('.')[0],
style: widget.style?.copyWith(fontFeatures: [const FontFeature.tabularFigures()]),
DateTime.now()
.difference(DateTime.parse(widget.startTime))
.toString()
.split('.')[0],
style: widget.style
?.copyWith(fontFeatures: [const FontFeature.tabularFigures()]),
);
}

View file

@ -1228,5 +1228,5 @@ packages:
source: hosted
version: "3.1.1"
sdks:
dart: ">=2.18.0 <4.0.0"
dart: ">=2.18.0 <3.0.0"
flutter: ">=3.3.0"

View file

@ -93,7 +93,8 @@ void main() {
''';
final decoded = jsonDecode(sampleJson) as List;
final List<EmoteBTTV> emotes = decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList();
final List<EmoteBTTV> emotes =
decoded.map((emote) => EmoteBTTV.fromJson(emote)).toList();
expect(emotes.length, 4);
final emoteIds = [

View file

@ -39,6 +39,7 @@ void main() {
expect(stream.title, 'A shorter title...');
expect(stream.viewerCount, 50051);
expect(stream.startedAt, '2021-09-23T20:06:22Z');
expect(stream.thumbnailUrl, 'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg');
expect(stream.thumbnailUrl,
'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg');
});
}