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:
parent
f17cef9523
commit
3af5406f60
74 changed files with 1049 additions and 481 deletions
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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].
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})';
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()]),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue