Merge pull request #102 from tommyxchow/dev

Release v1.2.0
This commit is contained in:
Tommy Chow 2022-04-01 19:05:16 -04:00 • committed by GitHub
commit d6701dbac4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 1526 additions and 968 deletions

View file

@ -114,14 +114,18 @@
- Search for channels and categories
- Watch live streams with chat
- Support for BTTV, FFZ, and 7TV emotes/badges
- View local chat user message history
- See the list of users in chat and filter them
- Emote menu
- Local chat user message history
- Chatters list with filter
- Theater and full-screen mode
- Picture-in-picture mode (iOS only)
- Sleep timer
- Block and unblock users
- Light, dark, and black (OLED) themes
- Picture-in-picture mode (iOS only)
- Customizable settings
And more!
## Motivation
A major problem with the official Twitch app for many users is that emotes from services such as [BetterTTV (BTTV)](https://chrome.google.com/webstore/detail/betterttv/ajopnjidmegmdimjlfnijceegpefgped), [FrankerFaceZ (FFZ)](https://chrome.google.com/webstore/detail/frankerfacez/fadndhdgpmmaapbmfcknlfgcflmmmieb), and [7TV](https://chrome.google.com/webstore/detail/7tv/ammjkodgmmoknidbanneddgankgfejfh) are not officially supported. Twitch is unaffiliated with these services, hence why they haven't been integrated officially.
@ -146,20 +150,20 @@ Frosty aims to bring these emotes and other general quality of life features to
## FAQ
### Why do certain animations and scrolling appear to be janky?
Due to Flutter, there may be some stutter and jank on the first installation and launch. After using and moving around the app for a bit the jank will be mitigated through shader warmup/caching and should be minimal on subsequent launches. Watching a stream with a relatively fast chat for a couple of minutes usually resolves it for me.
### Why am I getting ads even though I'm subscribed to the channel or have Turbo?
Even if you've logged in to the app, you'll still have to log in to the WebView so that you can be identified when the stream plays. You can do so by going to the settings and then under the "Account" section tap the "Log in to WebView" button.
### How can I change the quality of the stream?
### Can I change the quality of the stream?
On Android, you can change the stream quality by turning off the custom stream overlay in the settings and tapping the gear icon on the bottom right.
On iOS, sadly quality options aren't available through the native player and rely on an "auto" setting. There is no official API for getting the live stream URLs so specific quality options are not possible at this time.
### Why do certain animations and scrolling appear to be janky?
Due to the Flutter framework, there may be some stutter and jank on the first installation and launch. After using and moving around the app for a bit the jank will be mitigated through shader warmup/caching and should be minimal on subsequent launches. Watching a stream with a relatively fast chat for a couple of minutes usually resolves it.
### Why am I getting ads even though I'm subscribed to the channel or have Turbo?
Even if you've logged in to the app, you'll still have to log in to the WebView so that you can be identified when the stream plays. You can do so by going to the settings and then under the "Account" section tap the "Log in to WebView" button.
### Why is there a delay between the stream and chat?
On Android, the delay should be minimal. Try refreshing the player if you have a delay.
@ -178,11 +182,13 @@ This is caused by the Flutter framework and is being worked on (see [here](https
I'm limited to what is available in the [Twitch API](https://dev.twitch.tv/docs/api/reference), so certain features from the Twitch web or mobile app (e.g., voting on predictions and category viewer count) are sadly not available at the moment.
Other features related to ad-blocking or utilizing private APIs will likely not be implemented officially because they would violate Twitch's terms of service. My highest priority currently is staying on the app stores and focusing on making features for those builds.
I'll try to add as many features as possible but occasionally I may take a break or be limited in time due to studies and personal reasons.
### I'd like to report a bug or request a new feature. Where can I do that?
### Where can I report a bug or request a new feature?
You can open a new issue [here](https://github.com/tommyxchow/frosty/issues) with the appropriate labels and I'll take a look at it.
You can open a new issue [here](https://github.com/tommyxchow/frosty/issues) with the appropriate labels (e.g., "bug" or "feature request") and I'll take a look at it.
## License

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

19
assets/icons/logo.svg Normal file
View file

@ -0,0 +1,19 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="paint0_linear_2_12" x1="50" y1="0" x2="50" y2="100" gradientUnits="userSpaceOnUse">
<stop stop-color="#9146FF"/>
<stop offset="1"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="50" fill="url(#paint0_linear_2_12)"/>
<mask id="mask0_2_12" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="15" y="32" width="70" height="33">
<rect x="15" y="32" width="69.7793" height="33" fill="white"/>
</mask>
<g mask="url(#mask0_2_12)">
<path d="M6.24302 73.762L44.1917 35.8133C46.1718 33.8332 47.1619 32.8431 48.3035 32.4722C49.3077 32.1459 50.3895 32.1459 51.3937 32.4722C52.5353 32.8431 53.5254 33.8332 55.5054 35.8133L56.3666 36.6744L12.761 80.28L6.24302 73.762Z" fill="white"/>
<path d="M44.2383 35.8133C46.2184 33.8332 47.2085 32.8432 48.3501 32.4722C49.3543 32.1459 50.4361 32.1459 51.4403 32.4722C52.5819 32.8432 53.572 33.8332 55.552 35.8133L93.5008 73.762L86.9827 80.28L43.3772 36.6745L44.2383 35.8133Z" fill="white"/>
<g style="mix-blend-mode:lighten">
<rect x="38.6633" y="42.4162" width="61.6676" height="9.21788" transform="rotate(45 38.6633 42.4162)" fill="white"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "BrandingImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "BrandingImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "BrandingImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,56 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Frosty</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>frosty</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Frosty</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>frosty</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>

View file

@ -264,7 +264,7 @@ class TwitchApi {
if (userData.isNotEmpty) {
return UserTwitch.fromJson(userData.first);
} else {
return Future.error('User does not exist');
return Future.error('User does not exist!');
}
} else {
return Future.error('Failed to get user: ${decoded['message']}');
@ -326,6 +326,22 @@ class TwitchApi {
}
}
/// Returns a [CategoriesTwitch] object containing the category info corresponding to the provided [gameId].
Future<CategoriesTwitch> getCategory({
required Map<String, String> headers,
required String gameId,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/games?id=$gameId');
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
if (response.statusCode == 200) {
return CategoriesTwitch.fromJson(decoded);
} else {
return Future.error('Failed to get category: ${decoded['message']}');
}
}
/// Returns a [CategoriesTwitch] containing up to 20 categories/games closest matching the [query] and a cursor for further requests.
Future<CategoriesTwitch> searchCategories({
required Map<String, String> headers,
@ -406,7 +422,7 @@ class TwitchApi {
return [];
}
} else {
return Future.error('User does not exist');
return Future.error('User does not exist!');
}
}

View file

@ -19,5 +19,11 @@ const zeroWidthEmotes = [
'cvHazmat',
];
// Regex for matching strings that contain lower or upper case English characters.
/// Regex for matching strings that contain lower or upper case English characters.
final regexEnglish = RegExp(r'[a-zA-Z]');
/// The default badge width and height.
const defaultBadgeSize = 18.0;
/// The default emote width and height when none are provided.
const defaultEmoteSize = 28.0;

View file

@ -89,16 +89,16 @@ class MyApp extends StatelessWidget {
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w800,
color: Colors.black,
),
iconTheme: IconThemeData(color: Colors.black),
),
colorScheme: ColorScheme.fromSwatch(
primarySwatch: Colors.deepPurple,
accentColor: Colors.deepPurpleAccent,
accentColor: const Color(0xff9146ff),
),
toggleableActiveColor: Colors.deepPurpleAccent,
toggleableActiveColor: const Color(0xff9146ff),
tabBarTheme: const TabBarTheme(
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
@ -116,16 +116,16 @@ class MyApp extends StatelessWidget {
titleTextStyle: const TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w800,
),
),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
accentColor: Colors.deepPurpleAccent,
accentColor: const Color(0xff9146ff),
),
dialogBackgroundColor: Colors.grey.shade900,
toggleableActiveColor: Colors.deepPurpleAccent,
toggleableActiveColor: const Color(0xff9146ff),
);
final oledTheme = ThemeData(
@ -138,16 +138,16 @@ class MyApp extends StatelessWidget {
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w800,
),
),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
accentColor: Colors.deepPurpleAccent,
accentColor: const Color(0xff9146ff),
),
dialogBackgroundColor: Colors.black,
toggleableActiveColor: Colors.deepPurpleAccent,
toggleableActiveColor: const Color(0xff9146ff),
);
return Observer(

View file

@ -20,7 +20,7 @@ class CategoryTwitch {
@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class CategoriesTwitch {
final List<CategoryTwitch> data;
final Map<String, String> pagination;
final Map<String, String>? pagination;
const CategoriesTwitch(
this.data,

View file

@ -18,5 +18,7 @@ CategoriesTwitch _$CategoriesTwitchFromJson(Map<String, dynamic> json) =>
(json['data'] as List<dynamic>)
.map((e) => CategoryTwitch.fromJson(e as Map<String, dynamic>))
.toList(),
Map<String, String>.from(json['pagination'] as Map),
(json['pagination'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);

View file

@ -291,6 +291,8 @@ class Emote {
factory Emote.fromFFZ(EmoteFFZ emote, EmoteType type) => Emote(
name: emote.name,
zeroWidth: false,
width: emote.width,
height: emote.height,
url: 'https:' + (emote.urls.url4x ?? emote.urls.url2x ?? emote.urls.url1x),
type: type,
);
@ -308,6 +310,22 @@ class Emote {
Map<String, dynamic> toJson() => _$EmoteToJson(this);
}
const emoteType = [
'Twitch (Bits Tier)',
'Twitch (Follower)',
'Twitch (Subscriber)',
'Twitch (Global)',
'Twitch (Unlocked)',
'Twitch (Channel)',
'FFZ (Global)',
'FFZ (Channel)',
'BTTV (Global)',
'BTTV (Channel)',
'BTTV (Shared)',
'7TV (Global)',
'7TV (Channel)',
];
enum EmoteType {
twitchBits,
twitchFollower,

View file

@ -3,6 +3,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/models/badges.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/screens/channel/stores/chat_assets_store.dart';
@ -81,8 +82,8 @@ class IRCMessage {
List<InlineSpan> generateSpan({
required TextStyle? style,
required ChatAssetsStore assetsStore,
required double badgeHeight,
required double emoteHeight,
required double badgeScale,
required double emoteScale,
required bool isLightTheme,
bool showMessage = true,
bool useZeroWidth = false,
@ -95,6 +96,8 @@ class IRCMessage {
final sevenTVUserToBadges = assetsStore.userTo7TVBadges;
final bttvUserToBadge = assetsStore.userToBTTVBadges;
final ffzRoomInfo = assetsStore.ffzRoomInfo;
final badgeSize = defaultBadgeSize * badgeScale;
final emoteSize = defaultEmoteSize * emoteScale;
// The span list that will be used to render the chat message
final span = <InlineSpan>[];
@ -156,7 +159,7 @@ class IRCMessage {
span.add(
_createBadgeSpan(
badge: newBadge,
height: badgeHeight,
size: badgeSize,
backgroundColor: const Color(0xFF00AD03),
),
);
@ -178,7 +181,7 @@ class IRCMessage {
span.add(
_createBadgeSpan(
badge: newBadge,
height: badgeHeight,
size: badgeSize,
),
);
span.add(const TextSpan(text: ' '));
@ -197,7 +200,7 @@ class IRCMessage {
0,
_createBadgeSpan(
badge: badge,
height: badgeHeight,
size: badgeSize,
backgroundColor: color,
),
);
@ -207,7 +210,7 @@ class IRCMessage {
span.add(
_createBadgeSpan(
badge: badge,
height: badgeHeight,
size: badgeSize,
backgroundColor: color,
),
);
@ -222,7 +225,7 @@ class IRCMessage {
span.add(
_createBadgeSpan(
badge: userBTTVBadge,
height: badgeHeight,
size: badgeSize,
isSvg: true,
),
);
@ -236,7 +239,7 @@ class IRCMessage {
span.add(
_createBadgeSpan(
badge: badge,
height: badgeHeight,
size: badgeSize,
),
);
span.add(const TextSpan(text: ' '));
@ -319,21 +322,20 @@ class IRCMessage {
if (nextWordIsEmoji)
Text(
words[index],
style: textStyle?.copyWith(fontSize: emoteHeight - 5),
style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
...emoteStack.reversed.map(
(emote) => CachedNetworkImage(
imageUrl: emote.url,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: emoteHeight,
fadeInDuration: const Duration(),
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : emoteSize,
),
)
];
// Create the message for the tooltip
final message = emoteStack.reversed.map((emote) => emote.name).join(', ');
final tooltip = nextWordIsEmoji ? words[index] + ', ' + message : message;
final message = emoteStack.reversed.map((emote) => '${emote.name} - ${emoteType[emote.type.index]}');
localSpan.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
@ -354,17 +356,26 @@ class IRCMessage {
...emoteStack.reversed.map(
(emote) => CachedNetworkImage(
imageUrl: emote.url,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
fadeInDuration: const Duration(),
height: 80,
),
)
],
),
const SizedBox(height: 5.0),
Text(
tooltip,
style: const TextStyle(color: Colors.black),
Column(
children: [
Text(
nextWordIsEmoji
? '${words[index]} - Emoji'
: '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
style: const TextStyle(color: Colors.black),
),
Text(
nextWordIsEmoji ? message.join(', ') : message.skip(1).join(', '),
style: const TextStyle(color: Colors.black),
),
],
),
],
),
@ -388,13 +399,14 @@ class IRCMessage {
localSpan.add(
_createEmoteSpan(
emote: emote,
height: emoteHeight,
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: emoteHeight - 5)));
localSpan.add(_createEmojiSpan(emoji: word, style: textStyle?.copyWith(fontSize: emoteSize - 5)));
} else {
localSpan.add(_createTextSpan(text: word, style: textStyle));
}
@ -415,12 +427,13 @@ class IRCMessage {
span.add(
_createEmoteSpan(
emote: emote,
height: emoteHeight,
height: emote.height != null ? emote.height! * emoteScale : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
),
);
} else {
if (regexEmoji.hasMatch(word)) {
span.add(_createEmojiSpan(emoji: word, style: textStyle?.copyWith(fontSize: emoteHeight - 5)));
span.add(_createEmojiSpan(emoji: word, style: textStyle?.copyWith(fontSize: emoteSize - 5)));
} else {
span.add(_createTextSpan(text: word, style: textStyle));
}
@ -448,7 +461,7 @@ class IRCMessage {
static Widget _createBadgeWidget({
required Badge badge,
required double height,
required double size,
Color? backgroundColor,
bool? isSvg,
}) {
@ -457,30 +470,30 @@ class IRCMessage {
color: backgroundColor,
child: CachedNetworkImage(
imageUrl: badge.url,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: height,
fadeInDuration: const Duration(),
height: size,
width: size,
),
);
} else if (isSvg == true) {
return SvgPicture.network(
badge.url,
placeholderBuilder: (context) => const SizedBox(),
height: height,
height: size,
width: size,
);
} else {
return CachedNetworkImage(
imageUrl: badge.url,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
height: height,
fadeInDuration: const Duration(),
height: size,
width: size,
);
}
}
static WidgetSpan _createBadgeSpan({
required Badge badge,
required double height,
required double size,
Color? backgroundColor,
bool? isSvg,
}) {
@ -494,7 +507,7 @@ class IRCMessage {
children: [
_createBadgeWidget(
badge: badge,
height: 80,
size: 80,
backgroundColor: backgroundColor,
isSvg: isSvg,
),
@ -510,7 +523,7 @@ class IRCMessage {
preferBelow: false,
child: _createBadgeWidget(
badge: badge,
height: height,
size: size,
backgroundColor: backgroundColor,
isSvg: isSvg,
),
@ -521,23 +534,8 @@ class IRCMessage {
static WidgetSpan _createEmoteSpan({
required Emote emote,
required double height,
required double? width,
}) {
const emoteType = [
'Twitch (Bits Tier)',
'Twitch (Follower)',
'Twitch (Subscriber)',
'Twitch (Global)',
'Twitch (Unlocked)',
'Twitch (Channel)',
'FFZ (Global)',
'FFZ (Channel)',
'BTTV (Global)',
'BTTV (Channel)',
'BTTV (Shared)',
'7TV (Global)',
'7TV (Channel)',
];
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
@ -548,8 +546,7 @@ class IRCMessage {
children: [
CachedNetworkImage(
imageUrl: emote.url,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
fadeInDuration: const Duration(),
height: 80,
),
const SizedBox(height: 5.0),
@ -569,8 +566,9 @@ class IRCMessage {
child: CachedNetworkImage(
imageUrl: emote.url,
placeholder: (context, url) => const SizedBox(),
fadeInDuration: const Duration(seconds: 0),
fadeInDuration: const Duration(),
height: height,
width: width,
),
),
);

View file

@ -33,47 +33,53 @@ class Chat extends StatelessWidget {
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize),
child: Observer(
builder: (context) => ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 5.0),
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: chatStore.messages.length,
controller: chatStore.scrollController,
separatorBuilder: (context, index) => SizedBox(height: chatStore.settings.messageSpacing),
itemBuilder: (context, index) => Observer(
builder: (context) {
final message = chatStore.messages[index];
builder: (context) {
final showDividers = chatStore.settings.showChatMessageDividers;
if (message.user != null && message.user != chatStore.auth.user.details?.login) {
return InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false;
},
onLongPress: () => showModalBottomSheet(
context: context,
builder: (context) => ChatUserModal(
username: message.user!,
chatStore: chatStore,
userId: message.tags['user-id']!,
displayName: message.tags['display-name']!,
),
),
child: ChatMessage(
ircMessage: message,
assetsStore: chatStore.assetsStore,
settingsStore: chatStore.settings,
),
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 5.0),
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
itemCount: chatStore.messages.length,
controller: chatStore.scrollController,
separatorBuilder: (context, index) => showDividers
? Divider(
height: chatStore.settings.messageSpacing,
thickness: 1.0,
)
: SizedBox(height: chatStore.settings.messageSpacing),
itemBuilder: (context, index) => Observer(
builder: (context) {
final message = chatStore.messages[index];
final chatMessage = ChatMessage(
ircMessage: message,
assetsStore: chatStore.assetsStore,
settingsStore: chatStore.settings,
);
}
return ChatMessage(
ircMessage: message,
assetsStore: chatStore.assetsStore,
settingsStore: chatStore.settings,
);
},
),
),
if (message.user != null && message.user != chatStore.auth.user.details?.login) {
return InkWell(
onTap: () {
FocusScope.of(context).unfocus();
if (chatStore.assetsStore.showEmoteMenu) chatStore.assetsStore.showEmoteMenu = false;
},
onLongPress: () => showModalBottomSheet(
context: context,
builder: (context) => ChatUserModal(
chatStore: chatStore,
username: message.user!,
userId: message.tags['user-id']!,
displayName: message.tags['display-name']!,
),
),
child: chatMessage,
);
}
return chatMessage;
},
),
);
},
),
),
),
@ -101,10 +107,7 @@ class Chat extends StatelessWidget {
if (chatStore.assetsStore.showEmoteMenu)
SizedBox(
height: MediaQuery.of(context).size.height / 3,
child: EmoteMenu(
assetsStore: chatStore.assetsStore,
textController: chatStore.textController,
),
child: EmoteMenu(chatStore: chatStore),
),
],
),

View file

@ -3,14 +3,17 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/stores/chat_details_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
class ChatDetails extends StatelessWidget {
final ChatDetailsStore chatDetails;
final ChatStore chatStore;
final String userLogin;
const ChatDetails({
Key? key,
required this.chatDetails,
required this.chatStore,
required this.userLogin,
}) : super(key: key);
@ -29,6 +32,7 @@ class ChatDetails extends StatelessWidget {
Expanded(
child: ChattersList(
chatDetails: chatDetails,
chatStore: chatStore,
userLogin: userLogin,
),
),

View file

@ -2,18 +2,25 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/stores/chat_details_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class ChattersList extends StatefulWidget {
final ChatDetailsStore chatDetails;
final ChatStore chatStore;
final String userLogin;
const ChattersList({
Key? key,
required this.chatDetails,
required this.chatStore,
required this.userLogin,
}) : super(key: key);
@ -141,7 +148,24 @@ class _ChattersListState extends State<ChattersList> {
padding: const EdgeInsets.only(left: 10.0, right: 10.0, top: 5.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Text(users[index]),
(context, index) => InkWell(
child: Text(users[index]),
onLongPress: () async {
final userInfo = await context
.read<TwitchApi>()
.getUser(headers: context.read<AuthStore>().headersTwitch, userLogin: users[index]);
showModalBottomSheet(
context: context,
builder: (context) => ChatUserModal(
chatStore: widget.chatStore,
username: userInfo.login,
userId: userInfo.id,
displayName: userInfo.displayName,
),
);
},
),
childCount: users.length,
),
),

View file

@ -3,16 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_panel.dart';
import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart';
import 'package:frosty/screens/channel/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
class EmoteMenu extends StatefulWidget {
final ChatAssetsStore assetsStore;
final TextEditingController textController;
final ChatStore chatStore;
const EmoteMenu({
Key? key,
required this.assetsStore,
required this.textController,
required this.chatStore,
}) : super(key: key);
@override
@ -20,7 +18,7 @@ class EmoteMenu extends StatefulWidget {
}
class _EmoteMenuState extends State<EmoteMenu> {
late final PageController _pageContoller = PageController(initialPage: widget.assetsStore.emoteMenuIndex);
late final PageController _pageContoller = PageController(initialPage: widget.chatStore.assetsStore.emoteMenuIndex);
@override
Widget build(BuildContext context) {
@ -47,9 +45,9 @@ class _EmoteMenuState extends State<EmoteMenu> {
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
widget.assetsStore.emoteMenuIndex = index;
widget.chatStore.assetsStore.emoteMenuIndex = index;
},
style: index == widget.assetsStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey),
style: index == widget.chatStore.assetsStore.emoteMenuIndex ? null : TextButton.styleFrom(primary: Colors.grey),
child: Text(section),
),
),
@ -59,39 +57,34 @@ class _EmoteMenuState extends State<EmoteMenu> {
),
Expanded(
child: PageView(
onPageChanged: (index) => widget.assetsStore.emoteMenuIndex = index,
onPageChanged: (index) => widget.chatStore.assetsStore.emoteMenuIndex = index,
controller: _pageContoller,
children: [
RecentEmotesPanel(
assetsStore: widget.assetsStore,
textController: widget.textController,
chatStore: widget.chatStore,
),
Observer(
builder: (_) => EmoteMenuPanel(
assetsStore: widget.assetsStore,
textController: widget.textController,
emotes: widget.assetsStore.userEmoteToObject.values.toList(),
chatStore: widget.chatStore,
emotes: widget.chatStore.assetsStore.userEmoteToObject.values.toList(),
),
),
Observer(
builder: (_) => EmoteMenuPanel(
assetsStore: widget.assetsStore,
textController: widget.textController,
emotes: widget.assetsStore.bttvEmotes,
chatStore: widget.chatStore,
emotes: widget.chatStore.assetsStore.bttvEmotes,
),
),
Observer(
builder: (_) => EmoteMenuPanel(
assetsStore: widget.assetsStore,
textController: widget.textController,
emotes: widget.assetsStore.ffzEmotes,
chatStore: widget.chatStore,
emotes: widget.chatStore.assetsStore.ffzEmotes,
),
),
Observer(
builder: (_) => EmoteMenuPanel(
assetsStore: widget.assetsStore,
textController: widget.textController,
emotes: widget.assetsStore.sevenTVEmotes,
chatStore: widget.chatStore,
emotes: widget.chatStore.assetsStore.sevenTVEmotes,
),
),
],

View file

@ -1,18 +1,16 @@
import 'package:flutter/material.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/screens/channel/chat/emote_menu/emote_menu_section.dart';
import 'package:frosty/screens/channel/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/widgets/section_header.dart';
class EmoteMenuPanel extends StatelessWidget {
final ChatAssetsStore assetsStore;
final TextEditingController textController;
final ChatStore chatStore;
final List<Emote> emotes;
const EmoteMenuPanel({
Key? key,
required this.assetsStore,
required this.textController,
required this.chatStore,
required this.emotes,
}) : super(key: key);
@ -48,8 +46,7 @@ class EmoteMenuPanel extends StatelessWidget {
),
),
EmoteMenuSection(
assetsStore: assetsStore,
textController: textController,
chatStore: chatStore,
emotes: globalEmotes,
),
],
@ -58,8 +55,7 @@ class EmoteMenuPanel extends StatelessWidget {
child: SectionHeader('Channel Emotes'),
),
EmoteMenuSection(
assetsStore: assetsStore,
textController: textController,
chatStore: chatStore,
emotes: channelEmotes,
),
],
@ -68,8 +64,7 @@ class EmoteMenuPanel extends StatelessWidget {
child: SectionHeader('Subscribed Emotes'),
),
EmoteMenuSection(
assetsStore: assetsStore,
textController: textController,
chatStore: chatStore,
emotes: subEmotes,
),
],
@ -78,8 +73,7 @@ class EmoteMenuPanel extends StatelessWidget {
child: SectionHeader('Unlocked Emotes'),
),
EmoteMenuSection(
assetsStore: assetsStore,
textController: textController,
chatStore: chatStore,
emotes: miscEmotes,
),
],

View file

@ -1,51 +1,50 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/models/emotes.dart';
import 'package:frosty/screens/channel/stores/chat_assets_store.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:provider/provider.dart';
class EmoteMenuSection extends StatelessWidget {
final ChatAssetsStore assetsStore;
final TextEditingController textController;
final ChatStore chatStore;
final List<Emote> emotes;
const EmoteMenuSection({
Key? key,
required this.assetsStore,
required this.textController,
required this.chatStore,
required this.emotes,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverPadding(
padding: const EdgeInsets.all(10.0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(context, index) => GestureDetector(
onTap: () {
textController.text += ' ' + emotes[index].name;
assetsStore.recentEmotes.remove(emotes[index]);
assetsStore.recentEmotes.insert(0, emotes[index]);
},
child: Tooltip(
message: emotes[index].name,
preferBelow: false,
child: CachedNetworkImage(imageUrl: emotes[index].url),
return SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) => InkWell(
onTap: () => chatStore.addEmote(emotes[index]),
child: Tooltip(
message: emotes[index].name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Center(
child: CachedNetworkImage(
imageUrl: emotes[index].url,
height: emotes[index].height?.toDouble() ?? defaultEmoteSize,
width: emotes[index].width?.toDouble(),
),
),
),
),
childCount: emotes.length,
),
childCount: emotes.length,
),
);
}

View file

@ -1,71 +1,105 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/screens/channel/stores/chat_assets_store.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
class RecentEmotesPanel extends StatelessWidget {
final ChatAssetsStore assetsStore;
final TextEditingController textController;
class RecentEmotesPanel extends StatefulWidget {
final ChatStore chatStore;
const RecentEmotesPanel({
Key? key,
required this.assetsStore,
required this.textController,
required this.chatStore,
}) : super(key: key);
@override
State<RecentEmotesPanel> createState() => _RecentEmotesPanelState();
}
class _RecentEmotesPanelState extends State<RecentEmotesPanel> {
Future<void> _showClearDialog() {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Recent Emotes'),
content: const Text('Are you sure you want to clear your recent emotes?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
style: TextButton.styleFrom(primary: Colors.red),
),
ElevatedButton(
onPressed: () {
setState(widget.chatStore.assetsStore.recentEmotes.clear);
Navigator.pop(context);
},
child: const Text('Yes'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: SectionHeader(
'Recent Emotes',
padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
SliverToBoxAdapter(
child: Row(
children: [
const SectionHeader(
'Recent Emotes',
padding: EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
),
const Spacer(),
SizedBox(
height: 30,
child: TextButton(
onPressed: widget.chatStore.assetsStore.recentEmotes.isEmpty ? null : _showClearDialog,
child: const Text('Clear'),
),
),
],
),
),
SliverPadding(
padding: const EdgeInsets.all(10.0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final emote = assetsStore.recentEmotes[index];
final validEmotes = [...assetsStore.emoteToObject.values, ...assetsStore.userEmoteToObject.values];
final matchingEmotes = validEmotes.where((existingEmote) => existingEmote.name == emote.name);
SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).orientation == Orientation.portrait
? 8
: context.read<SettingsStore>().showVideo
? 6
: 16,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final emote = widget.chatStore.assetsStore.recentEmotes[index];
final validEmotes = [...widget.chatStore.assetsStore.emoteToObject.values, ...widget.chatStore.assetsStore.userEmoteToObject.values];
final matchingEmotes = validEmotes.where((existingEmote) => existingEmote.name == emote.name && existingEmote.type == emote.type);
return GestureDetector(
onTap: matchingEmotes.isNotEmpty
? () {
textController.text += ' ' + emote.name;
assetsStore.recentEmotes.removeWhere((recentEmote) => recentEmote.name == matchingEmotes.first.name);
assetsStore.recentEmotes.insert(0, matchingEmotes.first);
}
: null,
child: Tooltip(
message: emote.name,
preferBelow: false,
child: CachedNetworkImage(
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,
return InkWell(
onTap: matchingEmotes.isNotEmpty ? () => widget.chatStore.addEmote(emote) : null,
child: Tooltip(
message: emote.name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Center(
child: CachedNetworkImage(
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(),
),
),
),
);
},
childCount: assetsStore.recentEmotes.length,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
),
),
);
},
childCount: widget.chatStore.assetsStore.recentEmotes.length,
),
),
],

View file

@ -1,5 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/channel/chat/details/chat_details.dart';
import 'package:frosty/screens/channel/stores/chat_store.dart';
@ -11,60 +13,105 @@ class ChatBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) => Row(
children: [
IconButton(
icon: Icon(Icons.adaptive.more),
tooltip: 'Chat Details',
onPressed: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: ChatDetails(
chatDetails: chatStore.chatDetailsStore,
userLogin: chatStore.channelName,
),
builder: (context) {
final emotes = [
...chatStore.assetsStore.userEmoteToObject.values,
...chatStore.assetsStore.bttvEmotes,
...chatStore.assetsStore.ffzEmotes,
...chatStore.assetsStore.sevenTVEmotes
].where((emote) => emote.name.toLowerCase().contains(chatStore.textController.text.split(' ').last.toLowerCase())).toList();
return Column(
children: [
if (chatStore.settings.emoteAutocomplete && chatStore.showAutocomplete && emotes.isNotEmpty) ...[
const Divider(
height: 1.0,
thickness: 1.0,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 5,
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
suffixIcon: IconButton(
tooltip: 'Emote Menu',
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu;
},
SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
itemCount: emotes.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(emotes[index], autocompleteMode: true),
child: Tooltip(
message: emotes[index].name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
child: Center(
child: CachedNetworkImage(
imageUrl: emotes[index].url,
fadeInDuration: const Duration(),
height: emotes[index].height?.toDouble() ?? defaultEmoteSize,
width: emotes[index].width?.toDouble(),
),
),
),
),
),
isDense: true,
hintMaxLines: 1,
contentPadding: const EdgeInsets.all(10.0),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
),
hintText: chatStore.auth.isLoggedIn ? 'Send a message' : 'Log in to chat',
),
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
),
)
],
Row(
children: [
IconButton(
icon: Icon(Icons.adaptive.more),
tooltip: 'Chat Details',
onPressed: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: ChatDetails(
chatDetails: chatStore.chatDetailsStore,
chatStore: chatStore,
userLogin: chatStore.channelName,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
focusNode: chatStore.textFieldFocusNode,
minLines: 1,
maxLines: 5,
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
suffixIcon: IconButton(
tooltip: 'Emote Menu',
icon: const Icon(Icons.emoji_emotions_outlined),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu = !chatStore.assetsStore.showEmoteMenu;
},
),
hintMaxLines: 1,
contentPadding: const EdgeInsets.all(10.0),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
),
hintText: chatStore.auth.isLoggedIn ? 'Send a message' : 'Log in to chat',
),
controller: chatStore.textController,
onSubmitted: chatStore.sendMessage,
),
),
),
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send),
onPressed: chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null,
)
],
),
),
IconButton(
tooltip: 'Send',
icon: const Icon(Icons.send),
onPressed: chatStore.auth.isLoggedIn ? () => chatStore.sendMessage(chatStore.textController.text) : null,
)
],
),
],
);
},
);
}
}

View file

@ -31,8 +31,8 @@ class ChatMessage extends StatelessWidget {
children: ircMessage.generateSpan(
style: DefaultTextStyle.of(context).style,
assetsStore: assetsStore,
emoteHeight: settingsStore.emoteHeight,
badgeHeight: settingsStore.badgeHeight,
emoteScale: settingsStore.emoteScale,
badgeScale: settingsStore.badgeScale,
useZeroWidth: settingsStore.showZeroWidth,
useReadableColors: settingsStore.useReadableColors,
isLightTheme: Theme.of(context).brightness == Brightness.light,
@ -70,8 +70,8 @@ class ChatMessage extends StatelessWidget {
children: ircMessage.generateSpan(
style: DefaultTextStyle.of(context).style,
assetsStore: assetsStore,
emoteHeight: settingsStore.emoteHeight,
badgeHeight: settingsStore.badgeHeight,
emoteScale: settingsStore.emoteScale,
badgeScale: settingsStore.badgeScale,
showMessage: settingsStore.showDeletedMessages,
useZeroWidth: settingsStore.showZeroWidth,
useReadableColors: settingsStore.useReadableColors,
@ -126,8 +126,8 @@ class ChatMessage extends StatelessWidget {
children: ircMessage.generateSpan(
style: DefaultTextStyle.of(context).style,
assetsStore: assetsStore,
emoteHeight: settingsStore.emoteHeight,
badgeHeight: settingsStore.badgeHeight,
emoteScale: settingsStore.emoteScale,
badgeScale: settingsStore.badgeScale,
useZeroWidth: settingsStore.showZeroWidth,
useReadableColors: settingsStore.useReadableColors,
isLightTheme: Theme.of(context).brightness == Brightness.light,

View file

@ -76,28 +76,34 @@ class ChatUserModal extends StatelessWidget {
Expanded(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: chatStore.settings.messageScale),
child: ListView.separated(
reverse: true,
itemBuilder: (context, index) => InkWell(
onLongPress: () async {
await Clipboard.setData(ClipboardData(text: userMessages[index].message));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Message copied!'),
behavior: SnackBarBehavior.floating,
),
);
},
child: ChatMessage(
ircMessage: userMessages[index],
assetsStore: chatStore.assetsStore,
settingsStore: chatStore.settings,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: chatStore.settings.fontSize),
child: ListView.separated(
reverse: true,
itemBuilder: (context, index) => InkWell(
onLongPress: () async {
await Clipboard.setData(ClipboardData(text: userMessages[index].message));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Message copied!'),
behavior: SnackBarBehavior.floating,
),
);
},
child: ChatMessage(
ircMessage: userMessages[index],
assetsStore: chatStore.assetsStore,
settingsStore: chatStore.settings,
),
),
separatorBuilder: (context, index) => chatStore.settings.showChatMessageDividers
? Divider(
height: chatStore.settings.messageSpacing,
thickness: 1.0,
)
: SizedBox(height: chatStore.settings.messageSpacing),
itemCount: userMessages.length,
),
separatorBuilder: (context, index) => SizedBox(
height: chatStore.settings.messageSpacing,
),
itemCount: userMessages.length,
),
),
),

View file

@ -72,6 +72,9 @@ abstract class _ChatStoreBase with Store {
@readonly
var _autoScroll = true;
@readonly
var _showAutocomplete = false;
/// The logged-in user's appearance in chat.
@readonly
var _userState = const USERSTATE();
@ -129,7 +132,7 @@ abstract class _ChatStoreBase with Store {
}
});
// Add a listener to the textfield that will do the following when focused:
// Add a listener to the textfield focus that will do the following when focused:
// 1. Hide the emote menu if it is currently shown
// 2. Scroll to the latest message (after a brief delay)
textFieldFocusNode.addListener(() {
@ -142,6 +145,11 @@ 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(() => _showAutocomplete = textFieldFocusNode.hasFocus && textController.text.split(' ').last.isNotEmpty ? true : false);
}
/// Handle and process the provided string-representation of the IRC data.
@ -344,6 +352,28 @@ abstract class _ChatStoreBase with Store {
textController.clear();
}
/// Adds the given [emote] to the chat textfield.
@action
void addEmote(Emote emote, {bool autocompleteMode = false}) {
if (textController.text.isEmpty || textController.text.endsWith(' ')) {
textController.text += emote.name + ' ';
} else if (autocompleteMode && _showAutocomplete && textController.text.endsWith('')) {
final split = textController.text.split(' ')
..removeLast()
..add(emote.name + ' ');
textController.text = split.join(' ');
} else {
textController.text += ' ' + emote.name + ' ';
}
assetsStore.recentEmotes
..removeWhere((recentEmote) => recentEmote.name == emote.name && recentEmote.type == emote.type)
..insert(0, emote);
textController.selection = TextSelection.fromPosition(TextPosition(offset: textController.text.length));
}
/// Closes and disposes all the channels and controllers used by the store.
void dispose() {
_channel?.sink.close(1001);

View file

@ -43,6 +43,24 @@ mixin _$ChatStore on _ChatStoreBase, Store {
});
}
final _$_showAutocompleteAtom =
Atom(name: '_ChatStoreBase._showAutocomplete');
bool get showAutocomplete {
_$_showAutocompleteAtom.reportRead();
return super._showAutocomplete;
}
@override
bool get _showAutocomplete => showAutocomplete;
@override
set _showAutocomplete(bool value) {
_$_showAutocompleteAtom.reportWrite(value, super._showAutocomplete, () {
super._showAutocomplete = value;
});
}
final _$_userStateAtom = Atom(name: '_ChatStoreBase._userState');
USERSTATE get userState {
@ -107,6 +125,17 @@ mixin _$ChatStore on _ChatStoreBase, Store {
}
}
@override
void addEmote(Emote emote, {bool autocompleteMode = false}) {
final _$actionInfo = _$_ChatStoreBaseActionController.startAction(
name: '_ChatStoreBase.addEmote');
try {
return super.addEmote(emote, autocompleteMode: autocompleteMode);
} finally {
_$_ChatStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''

View file

@ -54,7 +54,8 @@ class _VideoOverlayState extends State<VideoOverlay> {
if (widget.videoStore.sleepTimer != null && widget.videoStore.sleepTimer!.isActive)
Row(
children: [
Text('Timer: ${widget.videoStore.timeRemaining.toString().split('.')[0]}'),
const Icon(Icons.timer),
Text(' ${widget.videoStore.timeRemaining.toString().split('.')[0]}'),
const Spacer(),
IconButton(
tooltip: 'Cancel Timer',
@ -70,9 +71,10 @@ class _VideoOverlayState extends State<VideoOverlay> {
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Dismiss'),
style: TextButton.styleFrom(primary: Colors.red),
),
Observer(
builder: (context) => TextButton(
builder: (context) => ElevatedButton(
onPressed: widget.videoStore.sleepHours == 0 && widget.videoStore.sleepMinutes == 0
? null
: () => widget.videoStore.updateSleepTimer(
@ -82,7 +84,6 @@ class _VideoOverlayState extends State<VideoOverlay> {
),
),
child: const Text('Set Timer'),
style: TextButton.styleFrom(primary: Colors.green),
),
),
],
@ -260,14 +261,16 @@ class _VideoOverlayState extends State<VideoOverlay> {
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 5.0),
Text(
'${widget.videoStore.streamInfo?.gameName} for ${NumberFormat().format(widget.videoStore.streamInfo?.viewerCount)} viewers',
'${widget.videoStore.streamInfo?.gameName} \u2022 ${NumberFormat().format(widget.videoStore.streamInfo?.viewerCount)} viewers',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w300,
),
),
],

View file

@ -125,7 +125,7 @@ class _VideoChatState extends State<VideoChat> {
final appBar = AppBar(
title: Text(
regexEnglish.hasMatch(_chatStore.displayName) ? _chatStore.displayName : _chatStore.displayName + ' (${_chatStore.channelName})',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
style: const TextStyle(fontSize: 20),
),
actions: [
IconButton(

View file

@ -37,7 +37,7 @@ class _SearchState extends State<Search> {
autocorrect: false,
decoration: InputDecoration(
isDense: true,
hintText: 'Search for a channel or category',
hintText: 'Find a channel or category',
contentPadding: const EdgeInsets.all(10.0),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(15.0)),
@ -64,12 +64,10 @@ class _SearchState extends State<Search> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_searchStore.searchHistory.isNotEmpty)
const Padding(
padding: EdgeInsets.all(15.0),
child: Text(
'HISTORY',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SectionHeader(
'History',
padding: headerPadding,
fontWeight: FontWeight.bold,
),
Expanded(
child: ListView(
@ -105,6 +103,7 @@ class _SearchState extends State<Search> {
child: SectionHeader(
'Channels',
padding: headerPadding,
fontWeight: FontWeight.bold,
),
),
SearchResultsChannels(
@ -115,6 +114,7 @@ class _SearchState extends State<Search> {
child: SectionHeader(
'Categories',
padding: headerPadding,
fontWeight: FontWeight.bold,
),
),
SearchResultsCategories(searchStore: _searchStore),

View file

@ -82,7 +82,10 @@ class SearchResultsChannels extends StatelessWidget {
padding: const EdgeInsets.all(10.0),
child: const Text(
'LIVE',
style: TextStyle(fontWeight: FontWeight.bold),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
)

View file

@ -5,6 +5,7 @@ import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/category.dart';
import 'package:mobx/mobx.dart';
part 'categories_store.g.dart';
class CategoriesStore = _CategoriesStoreBase with _$CategoriesStore;
@ -68,7 +69,7 @@ abstract class _CategoriesStoreBase with Store {
} else {
_categories.addAll(result.data);
}
_categoriesCursor = result.pagination['cursor'];
_categoriesCursor = result.pagination?['cursor'];
_error = null;
} on SocketException {

View file

@ -63,7 +63,7 @@ class CategoryStreams extends StatelessWidget {
centerTitle: true,
title: Text(
listStore.categoryInfo!.name,
style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.w800),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@ -95,6 +95,8 @@ class CategoryStreams extends StatelessWidget {
width: thumbnailWidth,
height: thumbnailHeight,
showUptime: context.read<SettingsStore>().showThumbnailUptime,
showThumbnail: context.read<SettingsStore>().showThumbnails,
showCategory: false,
),
);
},

View file

@ -13,10 +13,19 @@ class TopSection extends StatelessWidget {
child: Column(
children: const [
TabBar(
indicatorColor: Colors.deepPurpleAccent,
indicatorColor: Color(0xff9146ff),
tabs: [
Tab(text: 'Streams'),
Tab(text: 'Categories'),
Tab(
child: Text(
'Streams',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Tab(
child: Text(
'Categories',
style: TextStyle(fontWeight: FontWeight.bold),
)),
],
),
Expanded(

View file

@ -1,13 +1,17 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:frosty/api/twitch_api.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/video_chat.dart';
import 'package:frosty/screens/home/stores/list_store.dart';
import 'package:frosty/screens/home/top/category_streams.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
/// A tappable card widget that displays a stream's thumbnail and details.
class StreamCard extends StatelessWidget {
@ -16,6 +20,8 @@ class StreamCard extends StatelessWidget {
final int width;
final int height;
final bool showUptime;
final bool showThumbnail;
final bool showCategory;
const StreamCard({
Key? key,
@ -24,6 +30,8 @@ class StreamCard extends StatelessWidget {
required this.width,
required this.height,
required this.showUptime,
required this.showThumbnail,
this.showCategory = true,
}) : super(key: key);
@override
@ -63,28 +71,29 @@ class StreamCard extends StatelessWidget {
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 15.0),
padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: showThumbnail ? 15.0 : 5.0),
child: Row(
children: [
Flexible(
flex: 1,
child: showUptime
? Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
Container(
color: const Color.fromRGBO(0, 0, 0, 0.5),
padding: const EdgeInsets.symmetric(horizontal: 2.0),
child: Text(
DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0],
style: const TextStyle(fontSize: 12, color: Colors.white),
),
)
],
)
: thumbnail,
),
if (showThumbnail)
Flexible(
flex: 1,
child: showUptime
? Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
Container(
color: const Color.fromRGBO(0, 0, 0, 0.5),
padding: const EdgeInsets.symmetric(horizontal: 2.0),
child: Text(
DateTime.now().difference(DateTime.parse(streamInfo.startedAt)).toString().split('.')[0],
style: const TextStyle(fontSize: 12, color: Colors.white),
),
)
],
)
: thumbnail,
),
Flexible(
flex: 2,
child: Padding(
@ -105,7 +114,10 @@ class StreamCard extends StatelessWidget {
preferBelow: false,
child: Text(
streamerName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
@ -120,14 +132,53 @@ class StreamCard extends StatelessWidget {
child: Text(
streamInfo.title.trim(),
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
const SizedBox(height: 5.0),
Text(streamInfo.gameName),
const SizedBox(height: 5.0),
if (showCategory) ...[
InkWell(
child: Tooltip(
message: streamInfo.gameName,
preferBelow: false,
child: Text(
streamInfo.gameName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w300,
color: DefaultTextStyle.of(context).style.color?.withOpacity(0.8),
),
),
),
onTap: () async {
final category = await context
.read<TwitchApi>()
.getCategory(headers: context.read<AuthStore>().headersTwitch, gameId: streamInfo.gameId);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
listStore: ListStore(
twitchApi: context.read<TwitchApi>(),
authStore: context.read<AuthStore>(),
listType: ListType.category,
categoryInfo: category.data.first,
),
),
),
);
},
),
const SizedBox(height: 5.0),
],
Text(
'${NumberFormat().format(streamInfo.viewerCount)} viewers',
style: const TextStyle(fontSize: 12),
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.w300,
color: DefaultTextStyle.of(context).style.color?.withOpacity(0.8),
),
),
],
),

View file

@ -77,6 +77,7 @@ class _StreamsListState extends State<StreamsList> with AutomaticKeepAliveClient
streamInfo: _listStore.streams[index],
width: thumbnailWidth,
height: thumbnailHeight,
showThumbnail: context.read<SettingsStore>().showThumbnails,
showUptime: context.read<SettingsStore>().showThumbnailUptime,
),
);

View file

@ -1,10 +1,11 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/blocked_users.dart';
import 'package:frosty/screens/settings/widgets/profile_card.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/block_button.dart';
import 'package:webview_flutter/webview_flutter.dart';
class AccountSettings extends StatelessWidget {
@ -20,13 +21,16 @@ class AccountSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
'Account',
padding: EdgeInsets.all(10.0),
builder: (context) => ExpansionTile(
leading: const Icon(Icons.account_circle),
title: const Text(
'Account',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
children: [
ProfileCard(authStore: authStore),
if (authStore.isLoggedIn) ...[
ListTile(
@ -52,7 +56,7 @@ class AccountSettings extends StatelessWidget {
builder: (context) {
return Scaffold(
appBar: AppBar(
title: const Text('Log In to WebView'),
title: const Text('Log in to WebView'),
),
body: const WebView(
initialUrl: 'https://www.twitch.tv/login',
@ -69,3 +73,189 @@ class AccountSettings extends StatelessWidget {
);
}
}
class ProfileCard extends StatelessWidget {
final AuthStore authStore;
const ProfileCard({Key? key, required this.authStore}) : super(key: key);
Future<void> _showLoginDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => SimpleDialog(
children: [
ColoredBox(
color: const Color.fromRGBO(145, 70, 255, 0.8),
child: SimpleDialogOption(
padding: const EdgeInsets.all(24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/icons/TwitchGlitchWhite.png',
height: 25,
),
const SizedBox(width: 10),
const Text(
'Connect with Twitch',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
onPressed: () {
authStore.login();
Navigator.pop(context);
},
),
),
const SizedBox(height: 20.0),
const Center(
child: Text(
'Or',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
const SizedBox(height: 5.0),
SimpleDialogOption(
child: TextField(
textAlign: TextAlign.center,
decoration: const InputDecoration(
hintText: 'Token',
),
onSubmitted: (token) {
authStore.login(customToken: token);
Navigator.pop(context);
},
),
)
],
),
);
}
Future<void> _showLogoutDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Log Out'),
content: const Text('Are you sure you want to log out?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
style: TextButton.styleFrom(primary: Colors.red),
),
ElevatedButton(
onPressed: () {
authStore.logout();
Navigator.pop(context);
},
child: const Text('Yes'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Center(
child: Observer(
builder: (context) {
if (authStore.error != null) {
return ListTile(
title: const Text('Failed to Connect'),
trailing: OutlinedButton(
onPressed: authStore.init,
child: const Text('Try Again'),
),
);
}
if (authStore.isLoggedIn && authStore.user.details != null) {
return ListTile(
leading: CircleAvatar(
foregroundImage: CachedNetworkImageProvider(authStore.user.details!.profileImageUrl),
),
title: Text(
authStore.user.details!.displayName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
trailing: OutlinedButton.icon(
onPressed: () => _showLogoutDialog(context),
icon: const Icon(Icons.logout_outlined),
label: const Text('Log Out'),
style: OutlinedButton.styleFrom(primary: Colors.red),
),
);
}
return ListTile(
isThreeLine: true,
leading: const Icon(
Icons.no_accounts,
size: 40,
),
title: const Text('Anonymous User'),
subtitle: const Text('Log in to chat, view followed streams, and more.'),
trailing: ElevatedButton.icon(
onPressed: () => _showLoginDialog(context),
icon: const Icon(Icons.login),
label: const Text('Log In'),
),
);
},
),
);
}
}
class BlockedUsers extends StatelessWidget {
final AuthStore authStore;
const BlockedUsers({
Key? key,
required this.authStore,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Blocked Users'),
),
body: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await authStore.user.refreshBlockedUsers(headers: authStore.headersTwitch);
},
child: Observer(
builder: (context) {
if (authStore.user.blockedUsers.isEmpty) {
return const Center(
child: Text('You don\'t have any blocked users.'),
);
}
return ListView(
children: authStore.user.blockedUsers.map(
(user) {
final displayName = regexEnglish.hasMatch(user.displayName) ? user.displayName : user.displayName + ' (${user.userLogin})';
return ListTile(
title: Text(displayName),
trailing: BlockButton(
authStore: authStore,
targetUser: displayName,
targetUserId: user.userId,
),
);
},
).toList(),
);
},
),
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/section_header.dart';
@ -23,165 +24,216 @@ class _ChatSettingsState extends State<ChatSettings> {
final settingsStore = widget.settingsStore;
return Observer(
builder: (context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
'Chat',
padding: EdgeInsets.all(10.0),
builder: (context) => ExpansionTile(
leading: const Icon(Icons.chat),
title: const Text(
'Chat',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
children: [
SwitchListTile.adaptive(
title: const Text('Bottom Bar'),
subtitle: const Text('Shows the bottom text field bar.'),
value: settingsStore.showBottomBar,
onChanged: (newValue) => settingsStore.showBottomBar = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Emote Autocomplete'),
subtitle: const Text('Shows a bar that suggests matching emotes when typing.'),
value: settingsStore.emoteAutocomplete,
onChanged: settingsStore.showBottomBar ? (newValue) => settingsStore.emoteAutocomplete = newValue : null,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Zero-Width Emotes'),
subtitle: const Text('Shows "stacked" emotes from BetterTTV and 7TV.'),
value: settingsStore.showZeroWidth,
onChanged: (newValue) => settingsStore.showZeroWidth = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Readable Name Colors'),
subtitle: const Text('Adjusts the lightness value of overly bright/dark names.'),
value: settingsStore.useReadableColors,
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Show Deleted Messages'),
subtitle: const Text('Restores the original message of deleted messages.'),
value: settingsStore.showDeletedMessages,
onChanged: (newValue) => settingsStore.showDeletedMessages = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Message Dividers'),
subtitle: const Text('Shows a subtle divider between each message.'),
value: settingsStore.showChatMessageDividers,
onChanged: (newValue) => settingsStore.showChatMessageDividers = newValue,
),
ListTile(
isThreeLine: true,
title: const Text('Message Timestamps'),
subtitle: const Text('Shows timestamps for when a message was sent.'),
trailing: DropdownButton(
value: settingsStore.timestampType,
onChanged: (TimestampType? newTimestamp) => settingsStore.timestampType = newTimestamp!,
items: TimestampType.values
.map((TimestampType value) => DropdownMenuItem(
value: value,
child: Text(timestamps[value.index]),
))
.toList(),
),
SwitchListTile.adaptive(
title: const Text('Show bottom bar'),
value: settingsStore.showBottomBar,
onChanged: (newValue) => settingsStore.showBottomBar = newValue,
),
const SizedBox(height: 10.0),
const SectionHeader('Message Appearance'),
ExpansionTile(
title: const Text(
'Preview',
style: TextStyle(fontWeight: FontWeight.w500),
),
SwitchListTile.adaptive(
title: const Text('Show deleted messages'),
value: settingsStore.showDeletedMessages,
onChanged: (newValue) => settingsStore.showDeletedMessages = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Show zero-width emotes'),
subtitle: const Text('Makes "stacked" emotes from BetterTTV and 7TV visible in chat messages.'),
value: settingsStore.showZeroWidth,
onChanged: (newValue) => settingsStore.showZeroWidth = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Use readable colors for chat names'),
subtitle: const Text('Adjusts the lightness value of overly bright/dark names in chat.'),
value: settingsStore.useReadableColors,
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
),
ListTile(
isThreeLine: true,
title: const Text('Message timestamps'),
subtitle: const Text('Displays timestamps for when a chat message was sent.'),
trailing: DropdownButton(
value: settingsStore.timestampType,
onChanged: (TimestampType? newTimestamp) => settingsStore.timestampType = newTimestamp!,
items: TimestampType.values
.map((TimestampType value) => DropdownMenuItem(
value: value,
child: Text(timestamps[value.index]),
))
.toList(),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(20.0),
margin: const EdgeInsets.all(10.0),
decoration: BoxDecoration(
border: Border.all(color: Colors.deepPurple),
borderRadius: BorderRadius.circular(15.0),
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: settingsStore.fontSize),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
height: defaultBadgeSize * settingsStore.badgeScale,
width: defaultBadgeSize * settingsStore.badgeScale,
),
),
const TextSpan(text: ' Badge and emote preview. '),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
height: defaultEmoteSize * settingsStore.emoteScale,
width: defaultEmoteSize * settingsStore.emoteScale,
),
),
],
),
textScaleFactor: settingsStore.messageScale,
),
SizedBox(height: settingsStore.messageSpacing),
Text(
'Hello! Here\'s a text preview.',
textScaleFactor: settingsStore.messageScale,
),
SizedBox(height: settingsStore.messageSpacing),
Text(
'And another for spacing without an emote!',
textScaleFactor: settingsStore.messageScale,
),
],
),
),
),
),
const SizedBox(height: 10.0),
ExpansionTile(
title: const Text('Message appearance'),
],
),
const SizedBox(height: 10.0),
ListTile(
title: Row(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(20.0),
margin: const EdgeInsets.all(10.0),
decoration: BoxDecoration(
border: Border.all(color: Colors.deepPurple),
borderRadius: BorderRadius.circular(15.0),
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: settingsStore.fontSize),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: 'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
placeholder: (context, url) => const SizedBox(),
height: settingsStore.badgeHeight,
),
),
const TextSpan(text: ' Badge and emote preview '),
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: CachedNetworkImage(
imageUrl: 'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
placeholder: (context, url) => const SizedBox(),
height: settingsStore.emoteHeight,
),
),
],
),
textScaleFactor: settingsStore.messageScale,
),
SizedBox(height: settingsStore.messageSpacing),
Text(
'Hello! Here\'s a text preview.',
textScaleFactor: settingsStore.messageScale,
),
SizedBox(height: settingsStore.messageSpacing),
Text(
'And another for spacing without an emote!',
textScaleFactor: settingsStore.messageScale,
),
],
),
),
),
ListTile(
isThreeLine: true,
title: Text('Message scale: ${settingsStore.messageScale.toStringAsFixed(1)}x'),
subtitle: Slider.adaptive(
value: settingsStore.messageScale,
min: 0.5,
max: 2.0,
divisions: 15,
onChanged: (newValue) => settingsStore.messageScale = newValue,
),
),
ListTile(
title: Text('Message spacing: ${settingsStore.messageSpacing.toStringAsFixed(0)}'),
subtitle: Slider.adaptive(
value: settingsStore.messageSpacing,
min: 0.0,
max: 30.0,
divisions: 30,
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
),
),
ListTile(
isThreeLine: true,
title: Text('Font size: ${settingsStore.fontSize.toInt()}'),
subtitle: Slider.adaptive(
value: settingsStore.fontSize,
min: 5,
max: 20,
divisions: 15,
onChanged: (newValue) => settingsStore.fontSize = newValue,
),
),
ListTile(
title: Text('Badge height: ${settingsStore.badgeHeight.toStringAsFixed(0)}'),
subtitle: Slider.adaptive(
value: settingsStore.badgeHeight,
min: 10.0,
max: 50.0,
divisions: 8,
onChanged: (newValue) => settingsStore.badgeHeight = newValue,
),
),
ListTile(
title: Text('Emote height: ${settingsStore.emoteHeight.toStringAsFixed(0)}'),
subtitle: Slider.adaptive(
value: settingsStore.emoteHeight,
min: 10.0,
max: 50.0,
divisions: 8,
onChanged: (newValue) => settingsStore.emoteHeight = newValue,
),
),
const Text('Badge Scale'),
const Spacer(),
Text('${settingsStore.badgeScale.toStringAsFixed(2)}x'),
],
),
],
);
},
subtitle: Slider.adaptive(
value: settingsStore.badgeScale,
min: 0.25,
max: 3.0,
divisions: 11,
onChanged: (newValue) => settingsStore.badgeScale = newValue,
),
),
ListTile(
title: Row(
children: [
const Text('Emote Scale'),
const Spacer(),
Text('${settingsStore.emoteScale.toStringAsFixed(2)}x'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.emoteScale,
min: 0.25,
max: 3.0,
divisions: 11,
onChanged: (newValue) => settingsStore.emoteScale = newValue,
),
),
ListTile(
title: Row(
children: [
const Text('Message Scale'),
const Spacer(),
Text('${settingsStore.messageScale.toStringAsFixed(2)}x'),
],
),
subtitle: Slider.adaptive(
value: settingsStore.messageScale,
min: 0.5,
max: 2.0,
divisions: 6,
onChanged: (newValue) => settingsStore.messageScale = newValue,
),
),
ListTile(
title: Row(
children: [
const Text('Message Spacing'),
const Spacer(),
Text(settingsStore.messageSpacing.toStringAsFixed(0).toString()),
],
),
subtitle: Slider.adaptive(
value: settingsStore.messageSpacing,
min: 0.0,
max: 30.0,
divisions: 6,
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
),
),
ListTile(
title: Row(
children: [
const Text('Font Size'),
const Spacer(),
Text(settingsStore.fontSize.toInt().toString()),
],
),
subtitle: Slider.adaptive(
value: settingsStore.fontSize,
min: 5,
max: 20,
divisions: 15,
onChanged: (newValue) => settingsStore.fontSize = newValue,
),
),
],
),
);
}
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/section_header.dart';
class GeneralSettings extends StatelessWidget {
final SettingsStore settingsStore;
@ -13,35 +12,43 @@ class GeneralSettings extends StatelessWidget {
const themes = ['System', 'Light', 'Dark', 'Black'];
return Observer(
builder: (context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
'General',
padding: EdgeInsets.all(10.0),
builder: (context) => ExpansionTile(
leading: const Icon(Icons.settings),
title: const Text(
'General',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
children: [
ListTile(
title: const Text('Theme'),
trailing: DropdownButton(
value: settingsStore.themeType,
onChanged: (ThemeType? newTheme) => settingsStore.themeType = newTheme!,
items: ThemeType.values
.map((ThemeType value) => DropdownMenuItem(
value: value,
child: Text(themes[value.index]),
))
.toList(),
),
ListTile(
title: const Text('Theme'),
trailing: DropdownButton(
value: settingsStore.themeType,
onChanged: (ThemeType? newTheme) => settingsStore.themeType = newTheme!,
items: ThemeType.values
.map((ThemeType value) => DropdownMenuItem(
value: value,
child: Text(themes[value.index]),
))
.toList(),
),
),
SwitchListTile.adaptive(
title: const Text('Show stream uptime on thumbnails'),
value: settingsStore.showThumbnailUptime,
onChanged: (newValue) => settingsStore.showThumbnailUptime = newValue,
),
],
);
},
),
SwitchListTile.adaptive(
title: const Text('Stream Card Thumbnails'),
value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Stream Uptime on Thumbnails'),
subtitle: const Text('Shows the uptime of the stream in the HH:MM:SS format.'),
value: settingsStore.showThumbnailUptime,
onChanged: settingsStore.showThumbnails ? (newValue) => settingsStore.showThumbnailUptime = newValue : null,
),
],
),
);
}
}

View file

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
class OtherSettings extends StatelessWidget {
final SettingsStore settingsStore;
@ -14,15 +15,48 @@ class OtherSettings extends StatelessWidget {
required this.settingsStore,
}) : super(key: key);
Future<void> _showConfirmDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset All Settings'),
content: const Text('Are you sure you want to reset all settings?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
style: TextButton.styleFrom(primary: Colors.red),
),
ElevatedButton(
onPressed: () {
settingsStore.reset();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings reset!'),
behavior: SnackBarBehavior.floating,
),
);
},
child: const Text('Yes'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
'Other',
padding: EdgeInsets.all(10.0),
return ExpansionTile(
leading: const Icon(Icons.help),
title: const Text(
'Other',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
children: [
ListTile(
leading: const Icon(Icons.info),
title: const Text('About Frosty'),
@ -31,15 +65,24 @@ class OtherSettings extends StatelessWidget {
showAboutDialog(
context: context,
applicationIcon: SvgPicture.asset(
'assets/icons/logo.svg',
height: 80,
),
applicationName: packageInfo.appName,
applicationVersion: 'Version ${packageInfo.version} (${packageInfo.buildNumber})',
applicationLegalese: '\u{a9} 2022 Tommy Chow',
);
},
),
ListTile(
leading: const Icon(Icons.launch),
title: const Text('FAQ'),
onTap: () => launch('https://github.com/tommyxchow/frosty#faq'),
),
Observer(
builder: (_) => SwitchListTile.adaptive(
title: const Text('Send anonymous crash logs'),
title: const Text('Send Anonymous Crash Logs'),
value: settingsStore.sendCrashLogs,
onChanged: (newValue) {
if (newValue == true) {
@ -52,9 +95,9 @@ class OtherSettings extends StatelessWidget {
),
),
Container(
padding: const EdgeInsets.all(10.0),
padding: const EdgeInsets.symmetric(horizontal: 15.0),
width: double.infinity,
child: ElevatedButton.icon(
child: OutlinedButton.icon(
icon: const Icon(Icons.delete_sweep),
label: const Text('Clear Image Cache'),
onPressed: () async {
@ -68,6 +111,15 @@ class OtherSettings extends StatelessWidget {
},
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
width: double.infinity,
child: OutlinedButton.icon(
icon: const Icon(Icons.restore),
label: const Text('Reset All Settings'),
onPressed: () => _showConfirmDialog(context),
),
),
],
);
}

View file

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/section_header.dart';
class VideoSettings extends StatelessWidget {
final SettingsStore settingsStore;
@ -13,42 +12,45 @@ class VideoSettings extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
'Video',
padding: EdgeInsets.all(10.0),
),
SwitchListTile.adaptive(
title: const Text('Show video'),
value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue,
),
builder: (context) => ExpansionTile(
leading: const Icon(Icons.ondemand_video),
title: const Text(
'Video',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
),
),
children: [
SwitchListTile.adaptive(
title: const Text('Video'),
value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue,
),
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Custom Overlay'),
subtitle: const Text('Replaces Twitch\'s default overlay with a mobile-friendly version.'),
value: settingsStore.showOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null,
),
if (Platform.isIOS)
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Use custom video overlay'),
subtitle: const Text('Replaces the default video overlay with a mobile-friendly version.'),
value: settingsStore.showOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.showOverlay = newValue : null,
title: const Text('Picture-in-Picture Button (experimental)'),
subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay. MAY CAUSE freezes/crashes.'),
value: settingsStore.pictureInPicture,
onChanged: settingsStore.showVideo && settingsStore.showOverlay ? (newValue) => settingsStore.pictureInPicture = newValue : null,
),
if (Platform.isIOS)
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Picture-in-picture button (experimental)'),
subtitle: const Text('Adds a button to enter PiP mode on the bottom right of the overlay. May cause freezes/crashes.'),
value: settingsStore.pictureInPicture,
onChanged: settingsStore.showVideo && settingsStore.showOverlay ? (newValue) => settingsStore.pictureInPicture = newValue : null,
),
SwitchListTile.adaptive(
title: const Text('Long-press player to toggle overlay'),
value: settingsStore.toggleableOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.toggleableOverlay = newValue : null,
),
],
);
},
SwitchListTile.adaptive(
isThreeLine: true,
title: const Text('Long-Press Player to Toggle Overlay'),
subtitle: const Text('Allows switching between Twitch\'s default overlay and the custom overlay.'),
value: settingsStore.toggleableOverlay,
onChanged: settingsStore.showVideo ? (newValue) => settingsStore.toggleableOverlay = newValue : null,
),
],
),
);
}
}

View file

@ -19,12 +19,6 @@ class Settings extends StatelessWidget {
@override
Widget build(BuildContext context) {
const divider = Divider(
thickness: 1.0,
indent: 10.0,
endIndent: 10.0,
);
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
@ -46,17 +40,14 @@ class Settings extends StatelessWidget {
bottom: false,
child: ListView(
children: [
const SizedBox(height: 10.0),
AccountSettings(
settingsStore: settingsStore,
authStore: context.read<AuthStore>(),
),
divider,
GeneralSettings(settingsStore: settingsStore),
divider,
VideoSettings(settingsStore: settingsStore),
divider,
ChatSettings(settingsStore: settingsStore),
divider,
OtherSettings(settingsStore: settingsStore),
],
),

View file

@ -12,86 +12,177 @@ class SettingsStore extends _SettingsStoreBase with _$SettingsStore {
}
abstract class _SettingsStoreBase with Store {
//General Settings
@JsonKey(defaultValue: ThemeType.system, unknownEnumValue: ThemeType.system)
@observable
var themeType = ThemeType.system;
// * General Settings
static const defaultThemeType = ThemeType.system;
@JsonKey(defaultValue: false)
@observable
var showThumbnailUptime = false;
static const defaultShowThumbnailUptime = false;
// Video Settings
@JsonKey(defaultValue: true)
@observable
var showVideo = true;
static const defaultShowThumbnails = true;
@JsonKey(defaultValue: true)
@JsonKey(defaultValue: defaultThemeType, unknownEnumValue: ThemeType.system)
@observable
var showOverlay = true;
var themeType = defaultThemeType;
@JsonKey(defaultValue: false)
@JsonKey(defaultValue: defaultShowThumbnailUptime)
@observable
var toggleableOverlay = false;
var showThumbnailUptime = defaultShowThumbnailUptime;
@JsonKey(defaultValue: false)
@JsonKey(defaultValue: defaultShowThumbnails)
@observable
var pictureInPicture = false;
var showThumbnails = defaultShowThumbnails;
// Chat Settings
@JsonKey(defaultValue: true)
@observable
var showBottomBar = true;
// * Video Settings
static const defaultShowVideo = true;
@JsonKey(defaultValue: false)
@observable
var showDeletedMessages = false;
static const defaultShowOverlay = true;
@JsonKey(defaultValue: false)
@observable
var showZeroWidth = false;
static const defaultToggleableOverlay = false;
@JsonKey(defaultValue: TimestampType.disabled, unknownEnumValue: TimestampType.disabled)
@observable
var timestampType = TimestampType.disabled;
static const defaultPictureInPicture = false;
@JsonKey(defaultValue: true)
@JsonKey(defaultValue: defaultShowVideo)
@observable
var useReadableColors = true;
var showVideo = defaultShowVideo;
@JsonKey(defaultValue: 1.0)
@JsonKey(defaultValue: defaultShowOverlay)
@observable
var messageScale = 1.0;
var showOverlay = defaultShowOverlay;
@JsonKey(defaultValue: 14.0)
@JsonKey(defaultValue: defaultToggleableOverlay)
@observable
var fontSize = 14.0;
var toggleableOverlay = defaultToggleableOverlay;
@JsonKey(defaultValue: 10.0)
@JsonKey(defaultValue: defaultPictureInPicture)
@observable
var messageSpacing = 10.0;
var pictureInPicture = defaultPictureInPicture;
@JsonKey(defaultValue: 20.0)
@observable
var badgeHeight = 20.0;
// * Chat Settings
static const defaultShowBottomBar = true;
@JsonKey(defaultValue: 30.0)
@observable
var emoteHeight = 30.0;
static const defaultShowDeletedMessages = false;
// Other settings
@JsonKey(defaultValue: true)
@observable
var sendCrashLogs = true;
static const defaultShowZeroWidth = false;
// Global configs
@JsonKey(defaultValue: false)
@observable
var fullScreen = false;
static const defaultShowChatMessageDividers = false;
@JsonKey(defaultValue: true)
static const defaultTimestampType = TimestampType.disabled;
static const defaultUseReadableColors = true;
static const defaultMessageScale = 1.0;
static const defaultFontSize = 12.0;
static const defaultMessageSpacing = 10.0;
static const defaultBadgeScale = 1.0;
static const defaultEmoteScale = 1.0;
static const defaultEmoteAutocomplete = true;
@JsonKey(defaultValue: defaultShowBottomBar)
@observable
var expandInfo = true;
var showBottomBar = defaultShowBottomBar;
@JsonKey(defaultValue: defaultShowDeletedMessages)
@observable
var showDeletedMessages = defaultShowDeletedMessages;
@JsonKey(defaultValue: defaultShowZeroWidth)
@observable
var showZeroWidth = defaultShowZeroWidth;
@JsonKey(defaultValue: defaultShowChatMessageDividers)
@observable
var showChatMessageDividers = defaultShowChatMessageDividers;
@JsonKey(defaultValue: defaultTimestampType, unknownEnumValue: TimestampType.disabled)
@observable
var timestampType = defaultTimestampType;
@JsonKey(defaultValue: defaultUseReadableColors)
@observable
var useReadableColors = defaultUseReadableColors;
@JsonKey(defaultValue: defaultFontSize)
@observable
var fontSize = defaultFontSize;
@JsonKey(defaultValue: defaultMessageSpacing)
@observable
var messageSpacing = defaultMessageSpacing;
@JsonKey(defaultValue: defaultMessageScale)
@observable
var messageScale = defaultMessageScale;
@JsonKey(defaultValue: defaultBadgeScale)
@observable
var badgeScale = defaultBadgeScale;
@JsonKey(defaultValue: defaultEmoteScale)
@observable
var emoteScale = defaultEmoteScale;
@JsonKey(defaultValue: defaultEmoteAutocomplete)
@observable
var emoteAutocomplete = defaultEmoteAutocomplete;
// * Other settings
static const defaultSendCrashLogs = true;
@JsonKey(defaultValue: defaultSendCrashLogs)
@observable
var sendCrashLogs = defaultSendCrashLogs;
// * Global configs
static const defaultFullScreen = false;
static const defaultExpandInfo = true;
@JsonKey(defaultValue: defaultFullScreen)
@observable
var fullScreen = defaultFullScreen;
@JsonKey(defaultValue: defaultExpandInfo)
@observable
var expandInfo = defaultExpandInfo;
@action
void reset() {
// * General Settings
themeType = defaultThemeType;
showThumbnailUptime = defaultShowThumbnailUptime;
showThumbnails = defaultShowThumbnails;
// * Video Settings
showVideo = defaultShowVideo;
showOverlay = defaultShowOverlay;
toggleableOverlay = defaultToggleableOverlay;
pictureInPicture = defaultPictureInPicture;
// * Chat Settings
showBottomBar = defaultShowBottomBar;
showDeletedMessages = defaultShowDeletedMessages;
showZeroWidth = defaultShowZeroWidth;
showChatMessageDividers = defaultShowChatMessageDividers;
timestampType = defaultTimestampType;
useReadableColors = defaultUseReadableColors;
messageScale = defaultMessageScale;
fontSize = defaultFontSize;
messageSpacing = defaultMessageSpacing;
badgeScale = defaultBadgeScale;
emoteScale = defaultEmoteScale;
emoteAutocomplete = defaultEmoteAutocomplete;
// * Other settings
sendCrashLogs = defaultSendCrashLogs;
// * Global configs
fullScreen = defaultFullScreen;
expandInfo = defaultExpandInfo;
}
}
enum ThemeType {

View file

@ -12,6 +12,7 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
unknownValue: ThemeType.system) ??
ThemeType.system
..showThumbnailUptime = json['showThumbnailUptime'] as bool? ?? false
..showThumbnails = json['showThumbnails'] as bool? ?? true
..showVideo = json['showVideo'] as bool? ?? true
..showOverlay = json['showOverlay'] as bool? ?? true
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
@ -19,16 +20,19 @@ SettingsStore _$SettingsStoreFromJson(Map<String, dynamic> json) =>
..showBottomBar = json['showBottomBar'] as bool? ?? true
..showDeletedMessages = json['showDeletedMessages'] as bool? ?? false
..showZeroWidth = json['showZeroWidth'] as bool? ?? false
..showChatMessageDividers =
json['showChatMessageDividers'] as bool? ?? false
..timestampType = $enumDecodeNullable(
_$TimestampTypeEnumMap, json['timestampType'],
unknownValue: TimestampType.disabled) ??
TimestampType.disabled
..useReadableColors = json['useReadableColors'] as bool? ?? true
..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0
..fontSize = (json['fontSize'] as num?)?.toDouble() ?? 14.0
..fontSize = (json['fontSize'] as num?)?.toDouble() ?? 12.0
..messageSpacing = (json['messageSpacing'] as num?)?.toDouble() ?? 10.0
..badgeHeight = (json['badgeHeight'] as num?)?.toDouble() ?? 20.0
..emoteHeight = (json['emoteHeight'] as num?)?.toDouble() ?? 30.0
..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0
..badgeScale = (json['badgeScale'] as num?)?.toDouble() ?? 1.0
..emoteScale = (json['emoteScale'] as num?)?.toDouble() ?? 1.0
..emoteAutocomplete = json['emoteAutocomplete'] as bool? ?? true
..sendCrashLogs = json['sendCrashLogs'] as bool? ?? true
..fullScreen = json['fullScreen'] as bool? ?? false
..expandInfo = json['expandInfo'] as bool? ?? true;
@ -37,6 +41,7 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
<String, dynamic>{
'themeType': _$ThemeTypeEnumMap[instance.themeType],
'showThumbnailUptime': instance.showThumbnailUptime,
'showThumbnails': instance.showThumbnails,
'showVideo': instance.showVideo,
'showOverlay': instance.showOverlay,
'toggleableOverlay': instance.toggleableOverlay,
@ -44,13 +49,15 @@ Map<String, dynamic> _$SettingsStoreToJson(SettingsStore instance) =>
'showBottomBar': instance.showBottomBar,
'showDeletedMessages': instance.showDeletedMessages,
'showZeroWidth': instance.showZeroWidth,
'showChatMessageDividers': instance.showChatMessageDividers,
'timestampType': _$TimestampTypeEnumMap[instance.timestampType],
'useReadableColors': instance.useReadableColors,
'messageScale': instance.messageScale,
'fontSize': instance.fontSize,
'messageSpacing': instance.messageSpacing,
'badgeHeight': instance.badgeHeight,
'emoteHeight': instance.emoteHeight,
'messageScale': instance.messageScale,
'badgeScale': instance.badgeScale,
'emoteScale': instance.emoteScale,
'emoteAutocomplete': instance.emoteAutocomplete,
'sendCrashLogs': instance.sendCrashLogs,
'fullScreen': instance.fullScreen,
'expandInfo': instance.expandInfo,
@ -107,6 +114,21 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
final _$showThumbnailsAtom = Atom(name: '_SettingsStoreBase.showThumbnails');
@override
bool get showThumbnails {
_$showThumbnailsAtom.reportRead();
return super.showThumbnails;
}
@override
set showThumbnails(bool value) {
_$showThumbnailsAtom.reportWrite(value, super.showThumbnails, () {
super.showThumbnails = value;
});
}
final _$showVideoAtom = Atom(name: '_SettingsStoreBase.showVideo');
@override
@ -215,6 +237,23 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
final _$showChatMessageDividersAtom =
Atom(name: '_SettingsStoreBase.showChatMessageDividers');
@override
bool get showChatMessageDividers {
_$showChatMessageDividersAtom.reportRead();
return super.showChatMessageDividers;
}
@override
set showChatMessageDividers(bool value) {
_$showChatMessageDividersAtom
.reportWrite(value, super.showChatMessageDividers, () {
super.showChatMessageDividers = value;
});
}
final _$timestampTypeAtom = Atom(name: '_SettingsStoreBase.timestampType');
@override
@ -246,21 +285,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
final _$messageScaleAtom = Atom(name: '_SettingsStoreBase.messageScale');
@override
double get messageScale {
_$messageScaleAtom.reportRead();
return super.messageScale;
}
@override
set messageScale(double value) {
_$messageScaleAtom.reportWrite(value, super.messageScale, () {
super.messageScale = value;
});
}
final _$fontSizeAtom = Atom(name: '_SettingsStoreBase.fontSize');
@override
@ -291,33 +315,64 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
final _$badgeHeightAtom = Atom(name: '_SettingsStoreBase.badgeHeight');
final _$messageScaleAtom = Atom(name: '_SettingsStoreBase.messageScale');
@override
double get badgeHeight {
_$badgeHeightAtom.reportRead();
return super.badgeHeight;
double get messageScale {
_$messageScaleAtom.reportRead();
return super.messageScale;
}
@override
set badgeHeight(double value) {
_$badgeHeightAtom.reportWrite(value, super.badgeHeight, () {
super.badgeHeight = value;
set messageScale(double value) {
_$messageScaleAtom.reportWrite(value, super.messageScale, () {
super.messageScale = value;
});
}
final _$emoteHeightAtom = Atom(name: '_SettingsStoreBase.emoteHeight');
final _$badgeScaleAtom = Atom(name: '_SettingsStoreBase.badgeScale');
@override
double get emoteHeight {
_$emoteHeightAtom.reportRead();
return super.emoteHeight;
double get badgeScale {
_$badgeScaleAtom.reportRead();
return super.badgeScale;
}
@override
set emoteHeight(double value) {
_$emoteHeightAtom.reportWrite(value, super.emoteHeight, () {
super.emoteHeight = value;
set badgeScale(double value) {
_$badgeScaleAtom.reportWrite(value, super.badgeScale, () {
super.badgeScale = value;
});
}
final _$emoteScaleAtom = Atom(name: '_SettingsStoreBase.emoteScale');
@override
double get emoteScale {
_$emoteScaleAtom.reportRead();
return super.emoteScale;
}
@override
set emoteScale(double value) {
_$emoteScaleAtom.reportWrite(value, super.emoteScale, () {
super.emoteScale = value;
});
}
final _$emoteAutocompleteAtom =
Atom(name: '_SettingsStoreBase.emoteAutocomplete');
@override
bool get emoteAutocomplete {
_$emoteAutocompleteAtom.reportRead();
return super.emoteAutocomplete;
}
@override
set emoteAutocomplete(bool value) {
_$emoteAutocompleteAtom.reportWrite(value, super.emoteAutocomplete, () {
super.emoteAutocomplete = value;
});
}
@ -366,11 +421,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
});
}
final _$_SettingsStoreBaseActionController =
ActionController(name: '_SettingsStoreBase');
@override
void reset() {
final _$actionInfo = _$_SettingsStoreBaseActionController.startAction(
name: '_SettingsStoreBase.reset');
try {
return super.reset();
} finally {
_$_SettingsStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
themeType: ${themeType},
showThumbnailUptime: ${showThumbnailUptime},
showThumbnails: ${showThumbnails},
showVideo: ${showVideo},
showOverlay: ${showOverlay},
toggleableOverlay: ${toggleableOverlay},
@ -378,13 +448,15 @@ pictureInPicture: ${pictureInPicture},
showBottomBar: ${showBottomBar},
showDeletedMessages: ${showDeletedMessages},
showZeroWidth: ${showZeroWidth},
showChatMessageDividers: ${showChatMessageDividers},
timestampType: ${timestampType},
useReadableColors: ${useReadableColors},
messageScale: ${messageScale},
fontSize: ${fontSize},
messageSpacing: ${messageSpacing},
badgeHeight: ${badgeHeight},
emoteHeight: ${emoteHeight},
messageScale: ${messageScale},
badgeScale: ${badgeScale},
emoteScale: ${emoteScale},
emoteAutocomplete: ${emoteAutocomplete},
sendCrashLogs: ${sendCrashLogs},
fullScreen: ${fullScreen},
expandInfo: ${expandInfo}

View file

@ -1,54 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants/constants.dart';
import 'package:frosty/core/auth/auth_store.dart';
import 'package:frosty/widgets/block_button.dart';
class BlockedUsers extends StatelessWidget {
final AuthStore authStore;
const BlockedUsers({
Key? key,
required this.authStore,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Blocked Users'),
),
body: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await authStore.user.refreshBlockedUsers(headers: authStore.headersTwitch);
},
child: Observer(
builder: (context) {
if (authStore.user.blockedUsers.isEmpty) {
return const Center(
child: Text('You don\'t have any blocked users.'),
);
}
return ListView(
children: authStore.user.blockedUsers.map(
(user) {
final displayName = regexEnglish.hasMatch(user.displayName) ? user.displayName : user.displayName + ' (${user.userLogin})';
return ListTile(
title: Text(displayName),
trailing: BlockButton(
authStore: authStore,
targetUser: displayName,
targetUserId: user.userId,
),
);
},
).toList(),
);
},
),
),
);
}
}

View file

@ -1,122 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/core/auth/auth_store.dart';
class ProfileCard extends StatelessWidget {
final AuthStore authStore;
const ProfileCard({Key? key, required this.authStore}) : super(key: key);
Future<void> _showLoginDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => SimpleDialog(
title: const Text('Log In With'),
children: [
ColoredBox(
color: const Color.fromRGBO(145, 70, 255, 0.8),
child: SimpleDialogOption(
padding: const EdgeInsets.all(24.0),
child: const Text(
'Twitch Account',
style: TextStyle(
fontSize: 16,
color: Colors.white,
),
),
onPressed: () {
authStore.login();
Navigator.pop(context);
},
),
),
const SizedBox(height: 20.0),
const Center(child: Text('Or')),
SimpleDialogOption(
child: TextField(
decoration: const InputDecoration(
hintText: 'Token',
),
onSubmitted: (token) {
authStore.login(customToken: token);
Navigator.pop(context);
},
),
)
],
),
);
}
Future<void> _showLogoutDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Log Out'),
content: const Text('Are you sure you want to log out?'),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
authStore.logout();
Navigator.pop(context);
},
child: const Text('Yes'),
style: TextButton.styleFrom(primary: Colors.red),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Center(
child: Observer(
builder: (context) {
if (authStore.error != null) {
return ListTile(
title: const Text('Failed to connect'),
trailing: OutlinedButton(
onPressed: authStore.init,
child: const Text('Try Again'),
),
);
}
if (authStore.isLoggedIn && authStore.user.details != null) {
return ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFFFFFFFF),
foregroundImage: CachedNetworkImageProvider(authStore.user.details!.profileImageUrl),
),
title: Text(authStore.user.details!.displayName),
trailing: ElevatedButton.icon(
onPressed: () => _showLogoutDialog(context),
icon: const Icon(Icons.logout_outlined),
label: const Text('Log Out'),
style: ElevatedButton.styleFrom(primary: Colors.red),
),
);
}
return ListTile(
isThreeLine: true,
leading: const Icon(
Icons.account_circle_outlined,
size: 40,
),
title: const Text('Anonymous User'),
subtitle: const Text('Log in to view your followed streams, send chat messages, and more.'),
trailing: OutlinedButton(
onPressed: () => _showLoginDialog(context),
child: const Text('Log In'),
),
);
},
),
);
}
}

View file

@ -32,8 +32,9 @@ class BlockButton extends StatelessWidget {
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
style: TextButton.styleFrom(primary: Colors.red),
),
TextButton(
ElevatedButton(
onPressed: () {
if (isBlocked) {
authStore.user.unblock(targetId: targetUserId, headers: authStore.headersTwitch);
@ -47,7 +48,6 @@ class BlockButton extends StatelessWidget {
Navigator.pop(context);
},
child: const Text('Yes'),
style: TextButton.styleFrom(primary: Colors.red),
),
],
),

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
class ScrollToTopButton extends StatelessWidget {
final ScrollController scrollController;
@ -21,7 +20,7 @@ class ScrollToTopButton extends StatelessWidget {
duration: const Duration(milliseconds: 500),
);
},
child: const FaIcon(Icons.arrow_drop_up),
child: const Icon(Icons.keyboard_arrow_up),
mini: true,
heroTag: null,
),

View file

@ -4,12 +4,14 @@ class SectionHeader extends StatelessWidget {
final String text;
final EdgeInsets padding;
final double fontSize;
final FontWeight fontWeight;
const SectionHeader(
this.text, {
Key? key,
this.padding = const EdgeInsets.fromLTRB(10.0, 30.0, 10.0, 5.0),
this.padding = const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 5.0),
this.fontSize = 18.0,
this.fontWeight = FontWeight.w600,
}) : super(key: key);
@override
@ -20,7 +22,7 @@ class SectionHeader extends StatelessWidget {
text,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
fontWeight: fontWeight,
),
),
);

View file

@ -7,21 +7,21 @@ packages:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "37.0.0"
version: "38.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "3.4.0"
version: "3.4.1"
archive:
dependency: transitive
description:
name: archive
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.2"
version: "3.3.0"
args:
dependency: transitive
description:
@ -264,7 +264,7 @@ packages:
name: flutter_native_splash
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
version: "2.1.2+1"
flutter_secure_storage:
dependency: "direct main"
description:
@ -337,7 +337,7 @@ packages:
name: font_awesome_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.0"
version: "10.1.0"
frontend_server_client:
dependency: transitive
description:
@ -764,7 +764,7 @@ packages:
name: shelf
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
version: "1.3.0"
shelf_web_socket:
dependency: transitive
description:
@ -846,7 +846,7 @@ packages:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
version: "3.0.0+2"
term_glyph:
dependency: transitive
description:
@ -986,7 +986,7 @@ packages:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.3"
version: "2.8.4"
webview_flutter_platform_interface:
dependency: transitive
description:
@ -1007,7 +1007,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.4"
version: "2.5.1"
xdg_directories:
dependency: transitive
description:

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.1.0+17
version: 1.2.0+18
environment:
sdk: ">=2.15.0 <3.0.0"
@ -29,7 +29,7 @@ environment:
dependencies:
flutter:
sdk: flutter
json_annotation: ^4.3.0
json_annotation: ^4.4.0
# The following adds the Cupertino Icons font to your application.
@ -99,7 +99,8 @@ flutter:
# - images/a_dot_ham.jpeg
assets:
- assets/icons/logo.png
- assets/icons/logo.svg
- assets/icons/TwitchGlitchWhite.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.

View file

@ -37,7 +37,7 @@
</head>
<body>
<picture id="splash">
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light) or (prefers-color-scheme: no-preference)">
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)">
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" />
</picture>