Update to Material 3/You and other changes/fixes (#280)

* Use adaptive refresh indicators

* Add new message count indicator in chat

* Sync pubspec.lock

* Initial migration to material 3

* Restore default bottom sheet style

* Remove FrostyListTile and reset styles

* Use "Clear" button for all search history

* Add `require_trailing_commas` lint rule

* Exclude generated files from linter

* Use ListView for safe area support in bottom sheet

* Add initial dark theme

* Make sizing and units consistent

* Update onbaording spacings & copy

* Migrate to default material 3 buttons

* Add headers to chat details bottom sheet

* Simplify token log in method

* Use bottom sheet for account options

* Remove `AnimateScale` widget

* Tweak search paddings

* Update message spacing divisions

* Upgrade packages

* Update message spacing divisions

* Regenerate splash screens

* Update Inter & add google fonts

* Add initial material 3 themes

* Use adaptive alert dialogs

* Revert to oultined inputs

* Fix tooltip message

* Revert add google_fonts

* Improve themes and various styling

* Convert categories grid to list

* Tweak stream card thumbnail radii

* Tweak settings headers

* Left align floating snackbars

* Tweak search header padding

* Fix profile pics occasionally not rounded

* Use profile pic for settings button if logged in

* Redesign sleep timer

* Use divider theme and reduce thickness

* Use 0 letter spacing for tab bar titles

* Redesign category streams list

* Add border to tooltips

* Increase category card font weight

* Use outlined icon btton for chat user modal

* Reduce profile pic radius in search results

* Fix scrolll to top button safe area

* Add support for reply threads

* Reduce tooltip and snackbar border thickness

* Rebuild reply threads on new messages

* Upgrade packages (iOS)

* Reduce font weight for all chat alerts

* Use tabbars for pageview

* Add dividers everywhere

* Revert "Add dividers everywhere"

This reverts commit 5f0d349fdb.

* Remove fill from inputs

* Add dividers to settings

* Use segmented button for settings list

* Add scrollbar to lists with scrollcontroller

* Redesign badge/emote tooltip

* Remove outline from chat user modal buttons

* Remove divider from reply thread

* Use tap for chatters list and increase font weight

* Constrain emote tooltip width
This commit is contained in:
Tommy Chow 2023-10-22 18:38:32 -04:00 • committed by GitHub
parent 06d71a593b
commit 9a6892746d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
93 changed files with 2331 additions and 2372 deletions

View file

@ -8,6 +8,9 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/**.g.dart
linter:
# The lint rules applied to this project can be customized in the
@ -31,6 +34,6 @@ linter:
- avoid_redundant_argument_values
- unnecessary_parenthesis
- always_declare_return_types
- require_trailing_commas
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View file

@ -7,6 +7,8 @@
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -3,38 +3,38 @@ PODS:
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Firebase/Analytics (10.10.0):
- Firebase/Analytics (10.15.0):
- Firebase/Core
- Firebase/Core (10.10.0):
- Firebase/Core (10.15.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 10.10.0)
- Firebase/CoreOnly (10.10.0):
- FirebaseCore (= 10.10.0)
- Firebase/Crashlytics (10.10.0):
- FirebaseAnalytics (~> 10.15.0)
- Firebase/CoreOnly (10.15.0):
- FirebaseCore (= 10.15.0)
- Firebase/Crashlytics (10.15.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 10.10.0)
- Firebase/Performance (10.10.0):
- FirebaseCrashlytics (~> 10.15.0)
- Firebase/Performance (10.15.0):
- Firebase/CoreOnly
- FirebasePerformance (~> 10.10.0)
- firebase_analytics (10.4.3):
- Firebase/Analytics (= 10.10.0)
- FirebasePerformance (~> 10.15.0)
- firebase_analytics (10.6.0):
- Firebase/Analytics (= 10.15.0)
- firebase_core
- Flutter
- firebase_core (2.14.0):
- Firebase/CoreOnly (= 10.10.0)
- firebase_core (2.18.0):
- Firebase/CoreOnly (= 10.15.0)
- Flutter
- firebase_crashlytics (3.3.3):
- Firebase/Crashlytics (= 10.10.0)
- firebase_crashlytics (3.4.0):
- Firebase/Crashlytics (= 10.15.0)
- firebase_core
- Flutter
- firebase_performance (0.9.2-3):
- Firebase/Performance (= 10.10.0)
- firebase_performance (0.9.3):
- Firebase/Performance (= 10.15.0)
- firebase_core
- Flutter
- FirebaseABTesting (10.11.0):
- FirebaseABTesting (10.16.0):
- FirebaseCore (~> 10.0)
- FirebaseAnalytics (10.10.0):
- FirebaseAnalytics/AdIdSupport (= 10.10.0)
- FirebaseAnalytics (10.15.0):
- FirebaseAnalytics/AdIdSupport (= 10.15.0)
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
@ -42,24 +42,24 @@ PODS:
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseAnalytics/AdIdSupport (10.10.0):
- FirebaseAnalytics/AdIdSupport (10.15.0):
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleAppMeasurement (= 10.10.0)
- GoogleAppMeasurement (= 10.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseCore (10.10.0):
- FirebaseCore (10.15.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreExtension (10.11.0):
- FirebaseCoreExtension (10.16.0):
- FirebaseCore (~> 10.0)
- FirebaseCoreInternal (10.11.0):
- FirebaseCoreInternal (10.16.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseCrashlytics (10.10.0):
- FirebaseCrashlytics (10.15.0):
- FirebaseCore (~> 10.5)
- FirebaseInstallations (~> 10.0)
- FirebaseSessions (~> 10.5)
@ -67,12 +67,12 @@ PODS:
- GoogleUtilities/Environment (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (~> 2.1)
- FirebaseInstallations (10.11.0):
- FirebaseInstallations (10.16.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1)
- FirebasePerformance (10.10.0):
- FirebasePerformance (10.15.0):
- FirebaseCore (~> 10.5)
- FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfig (~> 10.0)
@ -82,13 +82,13 @@ PODS:
- GoogleUtilities/ISASwizzler (~> 7.8)
- GoogleUtilities/MethodSwizzler (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseRemoteConfig (10.11.0):
- FirebaseRemoteConfig (10.16.0):
- FirebaseABTesting (~> 10.0)
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseSessions (10.11.0):
- FirebaseSessions (10.16.0):
- FirebaseCore (~> 10.5)
- FirebaseCoreExtension (~> 10.0)
- FirebaseInstallations (~> 10.0)
@ -104,49 +104,49 @@ PODS:
- FMDB (2.7.5):
- FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5)
- GoogleAppMeasurement (10.10.0):
- GoogleAppMeasurement/AdIdSupport (= 10.10.0)
- GoogleAppMeasurement (10.15.0):
- GoogleAppMeasurement/AdIdSupport (= 10.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleAppMeasurement/AdIdSupport (10.10.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.10.0)
- GoogleAppMeasurement/AdIdSupport (10.15.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleAppMeasurement/WithoutAdIdSupport (10.10.0):
- GoogleAppMeasurement/WithoutAdIdSupport (10.15.0):
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleDataTransport (9.2.3):
- GoogleDataTransport (9.2.5):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
- GoogleUtilities/AppDelegateSwizzler (7.11.5):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.11.1):
- GoogleUtilities/Environment (7.11.5):
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/ISASwizzler (7.11.1)
- GoogleUtilities/Logger (7.11.1):
- GoogleUtilities/ISASwizzler (7.11.5)
- GoogleUtilities/Logger (7.11.5):
- GoogleUtilities/Environment
- GoogleUtilities/MethodSwizzler (7.11.1):
- GoogleUtilities/MethodSwizzler (7.11.5):
- GoogleUtilities/Logger
- GoogleUtilities/Network (7.11.1):
- GoogleUtilities/Network (7.11.5):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.11.1)"
- GoogleUtilities/Reachability (7.11.1):
- "GoogleUtilities/NSData+zlib (7.11.5)"
- GoogleUtilities/Reachability (7.11.5):
- GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.11.1):
- GoogleUtilities/UserDefaults (7.11.5):
- GoogleUtilities/Logger
- in_app_review (0.2.0):
- Flutter
@ -160,9 +160,9 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.2.0)
- PromisesSwift (2.2.0):
- PromisesObjC (= 2.2.0)
- PromisesObjC (2.3.1)
- PromisesSwift (2.3.1):
- PromisesObjC (= 2.3.1)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@ -171,7 +171,7 @@ PODS:
- FMDB (>= 2.7.5)
- url_launcher_ios (0.0.1):
- Flutter
- wakelock (0.0.1):
- wakelock_plus (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
@ -192,7 +192,7 @@ DEPENDENCIES:
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock (from `.symlinks/plugins/wakelock/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS:
@ -247,48 +247,48 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock:
:path: ".symlinks/plugins/wakelock/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
Firebase: facd334e557a979bd03a0b58d90fd56b52b8aba0
firebase_analytics: 1a5ad75876257318ba5fdc6bf7aae73b6e98d0cf
firebase_core: 85b6664038311940ad60584eaabc73103c61f5de
firebase_crashlytics: cd12245f604099a4ef6557fb36490abe309a118a
firebase_performance: 1e6496ae6464861c0ed1bcda8c0d9b7440469502
FirebaseABTesting: 3f6b711718feb87346dc05f8c964601bc8fb2cf6
FirebaseAnalytics: 7bc7de519111dae802f5bc0c9c083918f8b8870d
FirebaseCore: d027ff503d37edb78db98429b11f580a24a7df2a
FirebaseCoreExtension: cacdad57fdb60e0b86dcbcac058ec78237946759
FirebaseCoreInternal: 9e46c82a14a3b3a25be4e1e151ce6d21536b89c0
FirebaseCrashlytics: bc8f3bed7f4d04be79d87391455efb4c9b163131
FirebaseInstallations: 2a2c6859354cbec0a228a863d4daf6de7c74ced4
FirebasePerformance: 7ae687b2d6e9656ecd62847053d61adcc96ba544
FirebaseRemoteConfig: 5f907cd5cf6f64d026c8bfd196a1086cbe1e95e2
FirebaseSessions: a62ba5c45284adb7714f4126cfbdb32b17c260bd
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
Firebase: 66043bd4579e5b73811f96829c694c7af8d67435
firebase_analytics: 6b016f3d8e86fec024f1a495834ae30d44fe4fe2
firebase_core: 49fd2395e3fbbf63f453888c8e9f42f541f4c11c
firebase_crashlytics: af0e347e4ab14d45da977c485568b0f0f1e697bb
firebase_performance: 1ecc6dc781c1cc9be43837ca5b9cdc517b55dfe6
FirebaseABTesting: 03f0a8b88cf618350527f2c6a2234e29b9c65064
FirebaseAnalytics: 47cef43728f81a839cf1306576bdd77ffa2eac7e
FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e
FirebaseCoreExtension: 2dbc745b337eb99d2026a7a309ae037bd873f45e
FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a
FirebaseCrashlytics: a83f26fb922a3fe181eb738fb4dcf0c92bba6455
FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee
FirebasePerformance: b7988bc06c87e9c3b9e6b1ae854cd460b646ebd6
FirebaseRemoteConfig: 17ec974c6cac5cdc6cf8297062c2219851857f06
FirebaseSessions: 96e7781e545929cde06dd91088ddbb0841391b43
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
GoogleAppMeasurement: bbbfd4bcb2b40ae9b772c3b0823a58c1e3d618f9
GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
GoogleAppMeasurement: 722db6550d1e6d552b08398b69a975ac61039338
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
PromisesSwift: cf9eb58666a43bbe007302226e510b16c1e10959
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
COCOAPODS: 1.11.3
COCOAPODS: 1.13.0

View file

@ -159,7 +159,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1300;
LastUpgradeCheck = 1430;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View file

@ -38,10 +38,14 @@ class BTTVApi {
final result = EmoteBTTVChannel.fromJson(decoded);
final emoteToUrl = <Emote>[];
emoteToUrl.addAll(result.channelEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)));
emoteToUrl.addAll(result.sharedEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)));
emoteToUrl.addAll(
result.channelEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)),
);
emoteToUrl.addAll(
result.sharedEmotes
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)),
);
return emoteToUrl;
} else {
@ -62,7 +66,7 @@ class BTTVApi {
return {
for (final badge in badgeObjects)
badge.providerId: ChatBadge.fromBTTV(badge)
badge.providerId: ChatBadge.fromBTTV(badge),
};
} else {
return Future.error('Failed to get BTTV badges');

View file

@ -17,8 +17,9 @@ class TwitchApi {
const TwitchApi(this._client);
/// Returns a list of all Twitch global emotes.
Future<List<Emote>> getEmotesGlobal(
{required Map<String, String> headers}) async {
Future<List<Emote>> getEmotesGlobal({
required Map<String, String> headers,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global');
final response = await _client.get(url, headers: headers);
@ -72,7 +73,8 @@ class TwitchApi {
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId');
'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId',
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
@ -97,8 +99,9 @@ class TwitchApi {
}
/// Returns a map of global Twitch badges to their [Emote] object.
Future<Map<String, ChatBadge>> getBadgesGlobal(
{required Map<String, String> headers}) async {
Future<Map<String, ChatBadge>> getBadgesGlobal({
required Map<String, String> headers,
}) async {
final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global');
final response = await _client.get(url, headers: headers);
@ -204,9 +207,11 @@ class TwitchApi {
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(cursor == null
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/streams'
: 'https://api.twitch.tv/helix/streams?after=$cursor');
: 'https://api.twitch.tv/helix/streams?after=$cursor',
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
@ -223,9 +228,11 @@ class TwitchApi {
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(cursor == null
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/streams/followed?user_id=$id'
: 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor');
: 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor',
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
@ -233,7 +240,8 @@ class TwitchApi {
return StreamsTwitch.fromJson(decoded);
} else {
return Future.error(
'Failed to get followed streams: ${decoded['message']}');
'Failed to get followed streams: ${decoded['message']}',
);
}
}
@ -243,9 +251,11 @@ class TwitchApi {
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(cursor == null
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/streams?game_id=$gameId'
: 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor');
: 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor',
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
@ -253,7 +263,8 @@ class TwitchApi {
return StreamsTwitch.fromJson(decoded);
} else {
return Future.error(
'Failed to get streams under category: ${decoded['message']}');
'Failed to get streams under category: ${decoded['message']}',
);
}
}
@ -286,9 +297,11 @@ class TwitchApi {
String? id,
required Map<String, String> headers,
}) async {
final url = Uri.parse(id != null
final url = Uri.parse(
id != null
? 'https://api.twitch.tv/helix/users?id=$id'
: 'https://api.twitch.tv/helix/users?login=$userLogin');
: 'https://api.twitch.tv/helix/users?login=$userLogin',
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
@ -312,7 +325,8 @@ class TwitchApi {
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/channels?broadcaster_id=$userId');
'https://api.twitch.tv/helix/channels?broadcaster_id=$userId',
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
@ -335,7 +349,8 @@ class TwitchApi {
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/search/channels?first=8&query=$query');
'https://api.twitch.tv/helix/search/channels?first=8&query=$query',
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
@ -352,9 +367,11 @@ class TwitchApi {
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(cursor == null
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/games/top'
: 'https://api.twitch.tv/helix/games/top?after=$cursor');
: 'https://api.twitch.tv/helix/games/top?after=$cursor',
);
final response = await _client.get(url, headers: headers);
final decoded = jsonDecode(response.body);
@ -362,7 +379,8 @@ class TwitchApi {
return CategoriesTwitch.fromJson(decoded);
} else {
return Future.error(
'Failed to get top categories: ${decoded['message']}');
'Failed to get top categories: ${decoded['message']}',
);
}
}
@ -388,9 +406,11 @@ class TwitchApi {
required String query,
String? cursor,
}) async {
final url = Uri.parse(cursor == null
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/search/categories?first=8&query=$query'
: 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor');
: 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor',
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
@ -408,7 +428,8 @@ class TwitchApi {
required Map<String, String> headers,
}) async {
final uri = Uri.parse(
'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId');
'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId',
);
final response = await _client.get(uri, headers: headers);
if (response.statusCode == 200) {
@ -426,9 +447,11 @@ class TwitchApi {
required Map<String, String> headers,
String? cursor,
}) async {
final url = Uri.parse(cursor == null
final url = Uri.parse(
cursor == null
? 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id'
: 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor');
: 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor',
);
final response = await _client.get(url, headers: headers);
if (response.statusCode == 200) {
@ -447,8 +470,13 @@ class TwitchApi {
// With the Twitch API, we can make up to 800 requests per minute.
// Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute.
await Future.delayed(const Duration(milliseconds: 150));
result.addAll(await getUserBlockedList(
id: id, cursor: cursor, headers: headers));
result.addAll(
await getUserBlockedList(
id: id,
cursor: cursor,
headers: headers,
),
);
}
return result;
@ -462,10 +490,13 @@ class TwitchApi {
}
// Blocks the user with the given ID and returns true on success or false on failure.
Future<bool> blockUser(
{required String userId, required Map<String, String> headers}) async {
Future<bool> blockUser({
required String userId,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId');
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
);
final response = await _client.put(url, headers: headers);
if (response.statusCode == 204) {
@ -476,10 +507,13 @@ class TwitchApi {
}
// Unblocks the user with the given ID and returns true on success or false on failure.
Future<bool> unblockUser(
{required String userId, required Map<String, String> headers}) async {
Future<bool> unblockUser({
required String userId,
required Map<String, String> headers,
}) async {
final url = Uri.parse(
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId');
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
);
final response = await _client.delete(url, headers: headers);
if (response.statusCode == 204) {

View file

@ -63,7 +63,8 @@ class DefaultFirebaseOptions {
messagingSenderId: '403282332389',
projectId: 'frosty-128de',
storageBucket: 'frosty-128de.appspot.com',
iosClientId: '403282332389-gst8k8gl71a7vrhf5k69nl8pnsq19rg8.apps.googleusercontent.com',
iosClientId:
'403282332389-gst8k8gl71a7vrhf5k69nl8pnsq19rg8.apps.googleusercontent.com',
iosBundleId: 'com.tommychow.frosty',
);
}

View file

@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
import 'package:firebase_core/firebase_core.dart';
@ -17,6 +16,7 @@ import 'package:frosty/screens/home/home.dart';
import 'package:frosty/screens/onboarding/onboarding_intro.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:http/http.dart';
import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart';
@ -91,124 +91,6 @@ void main() async {
// Navigator key for sleep timer. Allows navigation popping without context.
final navigatorKey = GlobalKey<NavigatorState>();
const gray = Color.fromRGBO(18, 18, 18, 1.0);
const lightGray = Color.fromRGBO(28, 28, 28, 1.0);
const purple = Color(0xff9146ff);
const inputTheme = InputDecorationTheme(
filled: true,
contentPadding: EdgeInsets.all(10.0),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(style: BorderStyle.none),
),
);
const tooltipTheme = TooltipThemeData(
padding: EdgeInsets.all(10.0),
margin: EdgeInsets.symmetric(horizontal: 5.0),
decoration: BoxDecoration(
color: lightGray,
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
textStyle: TextStyle(color: Colors.white),
);
const snackBarTheme = SnackBarThemeData(
backgroundColor: lightGray,
contentTextStyle: TextStyle(color: Colors.white),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
);
final lightTheme = ThemeData(
canvasColor: Colors.white,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: Colors.white,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black,
),
iconTheme: IconThemeData(color: Colors.black),
),
colorScheme: ColorScheme.fromSwatch(
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
tabBarTheme: const TabBarTheme(
labelColor: Colors.black,
unselectedLabelColor: Colors.grey,
),
inputDecorationTheme: inputTheme,
tooltipTheme: tooltipTheme,
snackBarTheme: snackBarTheme,
);
final darkTheme = ThemeData(
canvasColor: gray,
brightness: Brightness.dark,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: gray,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
bottomNavigationBarTheme:
const BottomNavigationBarThemeData(backgroundColor: gray),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
dialogBackgroundColor: gray,
inputDecorationTheme: inputTheme,
tooltipTheme: tooltipTheme,
snackBarTheme: snackBarTheme,
);
final oledTheme = ThemeData(
canvasColor: Colors.black,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
fontFamily: 'Inter',
appBarTheme: const AppBarTheme(
color: Colors.black,
elevation: 0.0,
titleTextStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
colorScheme: ColorScheme.fromSwatch(
brightness: Brightness.dark,
primarySwatch: Colors.deepPurple,
accentColor: purple,
),
dialogBackgroundColor: Colors.black,
inputDecorationTheme: inputTheme,
tooltipTheme: tooltipTheme,
snackBarTheme: snackBarTheme,
);
class MyApp extends StatefulWidget {
final bool firstRun;
@ -239,14 +121,14 @@ class _MyAppState extends State<MyApp> {
return Observer(
builder: (context) {
final settingsStore = context.read<SettingsStore>();
final themes = FrostyThemes();
return MaterialApp(
title: 'Frosty',
theme: lightTheme,
darkTheme: settingsStore.themeType == ThemeType.dark ||
settingsStore.themeType == ThemeType.system
? darkTheme
: oledTheme,
theme: themes.light,
darkTheme: settingsStore.themeType == ThemeType.black
? themes.black
: themes.dark,
themeMode: settingsStore.themeType == ThemeType.system
? ThemeMode.system
: settingsStore.themeType == ThemeType.light

View file

@ -152,5 +152,19 @@ enum BadgeType {
twitch,
bttv,
ffz,
sevenTV,
sevenTV;
@override
String toString() {
switch (this) {
case BadgeType.twitch:
return 'Twitch badge';
case BadgeType.bttv:
return 'BetterTTV badge';
case BadgeType.ffz:
return 'FrankerFaceZ badge';
case BadgeType.sevenTV:
return '7TV badge';
}
}
}

View file

@ -184,22 +184,6 @@ 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,
@ -213,5 +197,37 @@ enum EmoteType {
bttvChannel,
bttvShared,
sevenTVGlobal,
sevenTVChannel,
sevenTVChannel;
@override
String toString() {
switch (this) {
case EmoteType.twitchBits:
return 'Twitch bits emote';
case EmoteType.twitchFollower:
return 'Twitch follower emote';
case EmoteType.twitchSub:
return 'Twitch sub emote';
case EmoteType.twitchGlobal:
return 'Twitch global emote';
case EmoteType.twitchUnlocked:
return 'Twitch unlocked emote';
case EmoteType.twitchChannel:
return 'Twitch channel emote';
case EmoteType.ffzGlobal:
return 'FrankerFaceZ global emote';
case EmoteType.ffzChannel:
return 'FrankerFaceZ channel emote';
case EmoteType.bttvGlobal:
return 'BetterTTV global emote';
case EmoteType.bttvChannel:
return 'BetterTTV channel emote';
case EmoteType.bttvShared:
return 'BetterTTV shared emote';
case EmoteType.sevenTVGlobal:
return '7TV global emote';
case EmoteType.sevenTVChannel:
return '7TV channel emote';
}
}
}

View file

@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/badges.dart';
@ -46,7 +47,8 @@ class IRCMessage {
messages.clear();
bufferedMessages.clear();
messages.add(
IRCMessage.createNotice(message: 'Chat was cleared by a moderator'));
IRCMessage.createNotice(message: 'Chat was cleared by a moderator'),
);
return;
}
@ -106,7 +108,8 @@ class IRCMessage {
}
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
List<InlineSpan> generateSpan({
List<InlineSpan> generateSpan(
BuildContext context, {
required TextStyle? style,
required ChatAssetsStore assetsStore,
required double badgeScale,
@ -191,9 +194,11 @@ class IRCMessage {
span.add(
_createBadgeSpan(
context,
badge: newBadge,
size: badgeSize,
backgroundColor: const Color(0xFF00AD03),
launchExternal: launchExternal,
),
);
span.add(const TextSpan(text: ' '));
@ -215,8 +220,10 @@ class IRCMessage {
span.add(
_createBadgeSpan(
context,
badge: newBadge,
size: badgeSize,
launchExternal: launchExternal,
),
);
span.add(const TextSpan(text: ' '));
@ -234,9 +241,11 @@ class IRCMessage {
span.insert(
0,
_createBadgeSpan(
context,
badge: badge,
size: badgeSize,
backgroundColor: color,
launchExternal: launchExternal,
),
);
span.add(const TextSpan(text: ' '));
@ -244,9 +253,11 @@ class IRCMessage {
} else {
span.add(
_createBadgeSpan(
context,
badge: badge,
size: badgeSize,
backgroundColor: color,
launchExternal: launchExternal,
),
);
span.add(const TextSpan(text: ' '));
@ -259,9 +270,11 @@ class IRCMessage {
if (userBTTVBadge != null) {
span.add(
_createBadgeSpan(
context,
badge: userBTTVBadge,
size: badgeSize,
isSvg: true,
launchExternal: launchExternal,
),
);
span.add(const TextSpan(text: ' '));
@ -273,8 +286,10 @@ class IRCMessage {
for (final badge in user7TVBadges) {
span.add(
_createBadgeSpan(
context,
badge: badge,
size: badgeSize,
launchExternal: launchExternal,
),
);
span.add(const TextSpan(text: ' '));
@ -282,7 +297,8 @@ class IRCMessage {
}
var color = Color(
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')));
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')),
);
if (useReadableColors) {
final hsl = HSLColor.fromColor(color);
@ -385,59 +401,45 @@ class IRCMessage {
: emoteSize,
useFade: false,
),
)
),
];
// Create the message for the tooltip
final message = emoteStack.reversed.map(
(emote) => '${emote.name} - ${emoteType[emote.type.index]}');
(emote) => emote.name,
);
final emoji = words[index];
localSpan.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
richMessage: WidgetSpan(
child: Column(
children: [
Stack(
child: InkWell(
onTap: () => _showDetailsBottomSheet(
context,
leading: Stack(
alignment: AlignmentDirectional.center,
children: [
if (nextWordIsEmoji)
Text(
words[index],
style: textStyle?.copyWith(fontSize: 75),
emoji,
style: textStyle?.copyWith(fontSize: 40),
),
...emoteStack.reversed.map(
(emote) => FrostyCachedNetworkImage(
imageUrl: emote.url,
height: 80,
useFade: false,
width: 56,
),
)
],
),
const SizedBox(height: 5.0),
Column(
children: [
Text(
nextWordIsEmoji
? '${words[index]} - Emoji'
: '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
style: const TextStyle(color: Colors.white),
),
Text(
nextWordIsEmoji
? message.join(', ')
: message.skip(1).join(', '),
style: const TextStyle(color: Colors.white),
),
],
),
],
url: emoteStack.last.url,
title: nextWordIsEmoji
? emoji
: '${emoteStack.last.name} (${emoteStack.last.type})',
subtitle: Text(
'with ${nextWordIsEmoji ? message.join(' + ') : message.skip(1).join(' + ')}',
),
launchExternal: launchExternal,
),
preferBelow: false,
child: Stack(
alignment: AlignmentDirectional.center,
children: children,
@ -449,32 +451,43 @@ class IRCMessage {
// If the next word is neither an emote or emoji, add it as a text span.
if (nextEmote == null && !nextWordIsEmoji) {
localSpan.add(const TextSpan(text: ' '));
localSpan.add(_createTextSpan(
localSpan.add(
_createTextSpan(
text: words[index],
style: textStyle,
launchExternal: launchExternal));
launchExternal: launchExternal,
),
);
}
} else {
localSpan.add(
_createEmoteSpan(
context,
emote: emote,
height: emote.height != null
? emote.height! * emoteScale
: emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null,
launchExternal: launchExternal,
),
);
}
} else {
if (regexEmoji.hasMatch(word)) {
localSpan.add(_createEmojiSpan(
localSpan.add(
_createEmojiSpan(
emoji: word,
style: textStyle?.copyWith(fontSize: emoteSize - 5)));
style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
);
} else {
localSpan.add(_createTextSpan(
localSpan.add(
_createTextSpan(
text: word,
style: textStyle,
launchExternal: launchExternal));
launchExternal: launchExternal,
),
);
}
}
@ -505,7 +518,7 @@ class IRCMessage {
static Widget _createBadgeWidget({
required ChatBadge badge,
required double size,
double? size,
Color? backgroundColor,
bool? isSvg,
}) {
@ -535,35 +548,30 @@ class IRCMessage {
}
}
static WidgetSpan _createBadgeSpan({
static WidgetSpan _createBadgeSpan(
BuildContext context, {
required ChatBadge badge,
required double size,
required bool launchExternal,
Color? backgroundColor,
bool? isSvg,
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
richMessage: WidgetSpan(
child: Column(
children: [
_createBadgeWidget(
child: InkWell(
onTap: () => _showDetailsBottomSheet(
context,
leading: _createBadgeWidget(
badge: badge,
size: 80,
backgroundColor: backgroundColor,
isSvg: isSvg,
),
const SizedBox(height: 5.0),
Text(
badge.name,
style: const TextStyle(color: Colors.white),
url: badge.url,
title: badge.name,
subtitle: Text(badge.type.toString()),
launchExternal: launchExternal,
showCopyName: false,
),
],
),
),
preferBelow: false,
child: _createBadgeWidget(
badge: badge,
size: size,
@ -574,37 +582,27 @@ class IRCMessage {
);
}
static WidgetSpan _createEmoteSpan({
static WidgetSpan _createEmoteSpan(
BuildContext context, {
required Emote emote,
required double height,
required double? width,
required bool launchExternal,
}) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 3),
richMessage: WidgetSpan(
child: Column(
children: [
FrostyCachedNetworkImage(
child: InkWell(
onTap: () => _showDetailsBottomSheet(
context,
leading: FrostyCachedNetworkImage(
imageUrl: emote.url,
height: 80,
useFade: false,
width: 56,
),
const SizedBox(height: 5.0),
Text(
emote.name,
style: const TextStyle(color: Colors.white),
url: emote.url,
title: emote.name,
subtitle: Text(emote.type.toString()),
launchExternal: launchExternal,
),
Text(
emoteType[emote.type.index],
style: const TextStyle(color: Colors.white),
),
],
),
),
preferBelow: false,
child: FrostyCachedNetworkImage(
imageUrl: emote.url,
height: height,
@ -616,26 +614,96 @@ class IRCMessage {
);
}
static TextSpan _createTextSpan(
{required String text, required bool launchExternal, TextStyle? style}) {
static TextSpan _createTextSpan({
required String text,
required bool launchExternal,
TextStyle? style,
}) {
if (text.startsWith('@')) {
return TextSpan(
text: text, style: style?.copyWith(fontWeight: FontWeight.bold));
text: text,
style: style?.copyWith(fontWeight: FontWeight.bold),
);
} else if (RegExp(r'https?:\/\/').hasMatch(text)) {
return TextSpan(
text: text,
style: style?.copyWith(color: Colors.blue),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(text),
..onTap = () => launchUrl(
Uri.parse(text),
mode: launchExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
: LaunchMode.inAppWebView,
),
);
} else {
return TextSpan(text: text, style: style);
}
}
static void _showDetailsBottomSheet(
BuildContext context, {
required Widget leading,
required String url,
required String title,
required Widget subtitle,
required bool launchExternal,
bool showCopyName = true,
}) {
showModalBottomSheet(
context: context,
builder: (context) => ListView(
shrinkWrap: true,
primary: false,
children: [
ListTile(
leading: leading,
title: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
subtitle: subtitle,
),
if (showCopyName)
ListTile(
leading: const Icon(Icons.copy_rounded),
title: const Text('Copy name'),
onTap: () {
Clipboard.setData(ClipboardData(text: title));
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.copy_rounded),
title: const Text('Copy image URL'),
onTap: () {
Clipboard.setData(ClipboardData(text: url));
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.launch_rounded),
title: const Text('Open in browser'),
onTap: () {
launchUrl(
Uri.parse(url),
mode: launchExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
);
Navigator.pop(context);
},
),
],
),
);
}
/// Parses an IRC string and returns its corresponding [IRCMessage] object.
factory IRCMessage.fromString(String whole, {String? userLogin}) {
// We have three parts:
@ -713,7 +781,9 @@ class IRCMessage {
final String range;
if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring(
indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(','));
indexBetweenIdAndPositions + 1,
emoteIdAndPosition.indexOf(','),
);
} else {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1);
}
@ -780,7 +850,8 @@ class IRCMessage {
if (wordBuffer.isNotEmpty) {
split.addAll(
wordBuffer.toString().split(' ').where((element) => element != ''));
wordBuffer.toString().split(' ').where((element) => element != ''),
);
}
if (emojiBuffer.isNotEmpty) split.add(emojiBuffer.toString());
}

View file

@ -8,7 +8,6 @@ import 'package:frosty/apis/ffz_api.dart';
import 'package:frosty/apis/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/channel/chat/chat.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
@ -19,6 +18,7 @@ import 'package:frosty/screens/channel/video/video_overlay.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/notification.dart';
import 'package:provider/provider.dart';
@ -171,7 +171,7 @@ class _VideoChatState extends State<VideoChat> {
child: IgnorePointer(
ignoring: !videoBarVisible,
child: ColoredBox(
color: Theme.of(context).canvasColor,
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -180,7 +180,7 @@ class _VideoChatState extends State<VideoChat> {
streamInfo: _videoStore.streamInfo!,
tappableCategory: false,
),
const Divider(height: 1, thickness: 1),
const Divider(),
],
),
),
@ -235,7 +235,8 @@ class _VideoChatState extends State<VideoChat> {
_chatStore.settings.chatWidth,
color: _chatStore.settings.fullScreen
? Colors.black.withOpacity(
_chatStore.settings.fullScreenChatOverlayOpacity)
_chatStore.settings.fullScreenChatOverlayOpacity,
)
: Theme.of(context).scaffoldBackgroundColor,
child: chat,
);
@ -244,7 +245,7 @@ class _VideoChatState extends State<VideoChat> {
visible: settingsStore.fullScreenChatOverlay,
maintainState: true,
child: Theme(
data: darkTheme,
data: FrostyThemes().dark,
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
@ -282,7 +283,7 @@ class _VideoChatState extends State<VideoChat> {
children: settingsStore.landscapeChatLeftSide
? [overlayChat, Expanded(child: overlay)]
: [Expanded(child: overlay), overlayChat],
)
),
],
)
: Row(

View file

@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@ -7,7 +8,6 @@ import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/page_view.dart';
class Chat extends StatelessWidget {
@ -35,11 +35,14 @@ class Chat extends StatelessWidget {
children: [
MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaleFactor: chatStore.settings.messageScale),
textScaleFactor: chatStore.settings.messageScale,
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
child: Scrollbar(
controller: chatStore.scrollController,
child: Observer(
builder: (context) {
return ListView.builder(
@ -58,8 +61,9 @@ class Chat extends StatelessWidget {
),
),
),
),
Padding(
padding: const EdgeInsets.all(5.0),
padding: const EdgeInsets.all(4),
child: Observer(
builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
@ -67,13 +71,20 @@ class Chat extends StatelessWidget {
switchOutCurve: Curves.easeIn,
child: chatStore.autoScroll
? null
: Button(
padding: const EdgeInsets.symmetric(
horizontal: 15.0, vertical: 10.0),
: ElevatedButton.icon(
onPressed: chatStore.resumeScroll,
icon: const Icon(
Icons.keyboard_double_arrow_down_rounded),
child: const Text('Resume scroll'),
icon:
const Icon(Icons.arrow_downward_rounded),
label: Text(
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [
FontFeature.tabularFigures(),
],
),
),
),
),
),
@ -111,7 +122,11 @@ class Chat extends StatelessWidget {
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu
? FrostyPageView(
? Column(
children: [
const Divider(),
Expanded(
child: FrostyPageView(
headers: const [
'Recent',
'Twitch',
@ -131,7 +146,7 @@ class Chat extends StatelessWidget {
...[
chatStore.assetsStore.sevenTVEmotes,
chatStore.assetsStore.bttvEmotes,
chatStore.assetsStore.ffzEmotes
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
@ -139,6 +154,9 @@ class Chat extends StatelessWidget {
),
),
],
),
),
],
)
: null,
),

View file

@ -8,10 +8,8 @@ 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/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:intl/intl.dart';
class ChatDetails extends StatelessWidget {
final ChatDetailsStore chatDetailsStore;
@ -25,89 +23,96 @@ class ChatDetails extends StatelessWidget {
required this.userLogin,
}) : super(key: key);
Future<void> _showSleepTimerDialog(BuildContext context) {
return showDialog(
String formatDuration(Duration duration) {
if (duration.inMinutes < 60) {
return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}';
}
return '${duration.inHours} ${Intl.plural(duration.inHours, one: 'hour', other: 'hours')}';
}
String formatTimeLeft(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m left';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s left';
} else {
return '${seconds}s left';
}
}
Future<void> _showSleepTimer(BuildContext context) {
const durations = [
Duration(minutes: 5),
Duration(minutes: 10),
Duration(minutes: 15),
Duration(minutes: 30),
Duration(hours: 1),
Duration(hours: 2),
Duration(hours: 3),
Duration(hours: 4),
Duration(hours: 5),
Duration(hours: 6),
Duration(hours: 7),
Duration(hours: 8),
Duration(hours: 9),
Duration(hours: 10),
Duration(hours: 11),
Duration(hours: 12),
];
return showModalBottomSheet(
context: context,
builder: (context) => FrostyDialog(
title: 'Sleep Timer',
content: Observer(
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Opacity(
opacity: chatStore.sleepTimer != null &&
chatStore.sleepTimer!.isActive
? 1.0
: 0.5,
child: Row(
const SectionHeader(
'Sleep timer',
padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
),
Expanded(
child: ListView(
children: [
const Icon(Icons.timer_rounded),
Text(
' ${chatStore.timeRemaining.toString().split('.')[0]}',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()]),
),
const Spacer(),
IconButton(
tooltip: 'Cancel sleep timer',
onPressed: chatStore.cancelSleepTimer,
icon: const Icon(Icons.cancel_rounded),
),
],
),
),
Row(
children: [
DropdownButton(
value: chatStore.sleepHours,
items: List.generate(24, (index) => index)
.map((e) => DropdownMenuItem(
value: e, child: Text(e.toString())))
.toList(),
onChanged: (int? hours) => chatStore.sleepHours = hours!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Hours'),
],
),
Row(
children: [
DropdownButton(
value: chatStore.sleepMinutes,
items: List.generate(60, (index) => index)
.map((e) => DropdownMenuItem(
value: e, child: Text(e.toString())))
.toList(),
onChanged: (int? minutes) =>
chatStore.sleepMinutes = minutes!,
menuMaxHeight: 200,
),
const SizedBox(width: 10.0),
const Text('Minutes'),
],
),
],
),
),
actions: [
if (chatStore.sleepTimer?.isActive == true)
Observer(
builder: (context) => Button(
onPressed:
chatStore.sleepHours == 0 && chatStore.sleepMinutes == 0
? null
: () => chatStore.updateSleepTimer(
builder: (context) {
return ListTile(
leading: const Icon(Icons.close_rounded),
title: Text(
'Turn off (${formatTimeLeft(chatStore.timeRemaining)})',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
onTap: () {
chatStore.cancelSleepTimer();
Navigator.of(context).pop();
},
);
},
),
...durations.map(
(duration) => ListTile(
leading: const Icon(Icons.hourglass_top_rounded),
title: Text(formatDuration(duration)),
onTap: () {
chatStore.updateSleepTimer(
duration: duration,
onTimerFinished: () => navigatorKey.currentState
?.popUntil((route) => route.isFirst),
),
child: const Text('Set timer'),
);
Navigator.of(context).pop();
},
),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Close'),
],
),
),
],
),
@ -117,19 +122,19 @@ class ChatDetails extends StatelessWidget {
Future<void> _showClearDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Clear Recent Emotes',
message: 'Are you sure you want to clear your recent emotes?',
builder: (context) => AlertDialog.adaptive(
title: const Text('Clear recent emotes'),
content:
const Text('Are you sure you want to clear your recent emotes?'),
actions: [
Button(
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Yes'),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Cancel'),
),
],
),
);
@ -138,18 +143,24 @@ class ChatDetails extends StatelessWidget {
@override
Widget build(BuildContext context) {
final children = [
const SectionHeader(
'Chat modes',
padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
),
ListTile(
title: ChatModes(roomState: chatDetailsStore.roomState),
),
FrostyListTile(
const SectionHeader(
'More',
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
),
ListTile(
leading: const Icon(Icons.people_outline),
title: 'Chatters',
title: const Text('Chatters'),
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyBottomSheet(
child: SizedBox(
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
@ -162,38 +173,46 @@ class ChatDetails extends StatelessWidget {
),
),
),
),
FrostyListTile(
Observer(
builder: (context) {
return ListTile(
leading: const Icon(Icons.timer_outlined),
title: 'Sleep timer',
onTap: () => _showSleepTimerDialog(context),
title: Text(
'Sleep timer ${chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(chatStore.timeRemaining)})' : ''}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
FrostyListTile(
),
onTap: () => _showSleepTimer(context),
);
},
),
ListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: 'Clear recent emotes',
title: const Text('Clear recent emotes'),
onTap: () => _showClearDialog(context),
),
FrostyListTile(
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: 'Reconnect to chat',
title: const Text('Reconnect to chat'),
onTap: () {
chatStore.updateNotification('Reconnecting to chat...');
chatStore.connectToChat();
},
),
FrostyListTile(
ListTile(
leading: const Icon(Icons.refresh_rounded),
title: 'Refresh badges and emotes',
title: const Text('Refresh badges and emotes'),
onTap: () async {
await chatStore.getAssets();
chatStore.updateNotification('Badges and emotes refreshed');
},
),
FrostyListTile(
ListTile(
leading: const Icon(Icons.settings_outlined),
title: 'Settings',
title: const Text('Settings'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
@ -203,16 +222,10 @@ class ChatDetails extends StatelessWidget {
),
];
return FrostyBottomSheet(
child: MediaQuery.of(context).orientation == Orientation.landscape
? SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: ListView(
return ListView(
shrinkWrap: true,
primary: false,
children: children,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: children),
);
}
}

View file

@ -33,7 +33,7 @@ class _ChattersListState extends State<ChattersList> {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(10.0),
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Observer(
builder: (context) {
return TextField(
@ -41,7 +41,6 @@ class _ChattersListState extends State<ChattersList> {
focusNode: widget.chatDetailsStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
prefixIcon: const Icon(Icons.filter_list_rounded),
hintText: 'Filter chatters',
suffixIcon: widget
@ -67,7 +66,7 @@ class _ChattersListState extends State<ChattersList> {
),
),
Expanded(
child: RefreshIndicator(
child: RefreshIndicator.adaptive(
onRefresh: () async {
HapticFeedback.lightImpact();
@ -76,7 +75,9 @@ class _ChattersListState extends State<ChattersList> {
child: Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Observer(
Scrollbar(
controller: widget.chatDetailsStore.scrollController,
child: Observer(
builder: (context) {
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
@ -84,12 +85,7 @@ class _ChattersListState extends State<ChattersList> {
slivers: [
if (widget.chatDetailsStore.filterText.isEmpty)
SliverPadding(
padding: const EdgeInsets.only(
left: 10.0,
right: 10.0,
top: 20.0,
bottom: 10.0,
),
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
child: Text(
'${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found',
@ -103,17 +99,14 @@ class _ChattersListState extends State<ChattersList> {
if (widget.chatDetailsStore.chatUsers.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: AlertMessage(message: 'No chatters found'),
),
)
else if (widget.chatDetailsStore.filteredUsers.isEmpty)
else if (widget
.chatDetailsStore.filteredUsers.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child:
AlertMessage(message: 'No matching chatters'),
),
)
else
SliverList(
@ -121,28 +114,31 @@ class _ChattersListState extends State<ChattersList> {
(context, index) => InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10.0, vertical: 5.0),
child: Text(widget
.chatDetailsStore.filteredUsers
.elementAt(index)),
horizontal: 12,
vertical: 4,
),
onLongPress: () async {
HapticFeedback.lightImpact();
final userInfo = await context
.read<TwitchApi>()
.getUser(
child: Text(
widget.chatDetailsStore.filteredUsers
.elementAt(index),
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
),
onTap: () async {
final userInfo =
await context.read<TwitchApi>().getUser(
headers: context
.read<AuthStore>()
.headersTwitch,
userLogin: widget
.chatDetailsStore.filteredUsers
.elementAt(index));
userLogin: widget.chatDetailsStore
.filteredUsers
.elementAt(index),
);
if (!mounted) return;
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
@ -154,14 +150,15 @@ class _ChattersListState extends State<ChattersList> {
);
},
),
childCount:
widget.chatDetailsStore.filteredUsers.length,
childCount: widget
.chatDetailsStore.filteredUsers.length,
),
),
],
);
},
),
),
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
@ -170,7 +167,8 @@ class _ChattersListState extends State<ChattersList> {
child: widget.chatDetailsStore.showJumpButton
? ScrollToTopButton(
scrollController:
widget.chatDetailsStore.scrollController)
widget.chatDetailsStore.scrollController,
)
: null,
),
),

View file

@ -20,18 +20,22 @@ class EmoteMenuPanel extends StatelessWidget {
Widget build(BuildContext context) {
if (emotes != null) {
final globalEmotes = emotes!
.where((emote) =>
.where(
(emote) =>
emote.type == EmoteType.bttvGlobal ||
emote.type == EmoteType.ffzGlobal ||
emote.type == EmoteType.sevenTVGlobal)
emote.type == EmoteType.sevenTVGlobal,
)
.toList();
final channelEmotes = emotes!
.where((emote) =>
.where(
(emote) =>
emote.type == EmoteType.bttvChannel ||
emote.type == EmoteType.bttvShared ||
emote.type == EmoteType.ffzChannel ||
emote.type == EmoteType.sevenTVChannel)
emote.type == EmoteType.sevenTVChannel,
)
.toList();
return FrostyPageView(

View file

@ -6,7 +6,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:provider/provider.dart';
class EmoteMenuSection extends StatelessWidget {
class EmoteMenuSection extends StatefulWidget {
final ChatStore chatStore;
final List<Emote> emotes;
@ -16,8 +16,16 @@ class EmoteMenuSection extends StatelessWidget {
required this.emotes,
}) : super(key: key);
@override
State<EmoteMenuSection> createState() => _EmoteMenuSectionState();
}
class _EmoteMenuSectionState extends State<EmoteMenuSection>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
@ -28,23 +36,27 @@ class EmoteMenuSection extends StatelessWidget {
: 16,
),
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(emotes[index]),
onTap: () => widget.chatStore.addEmote(widget.emotes[index]),
child: Tooltip(
message: emotes[index].name,
message: widget.emotes[index].name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: emotes[index].url,
height: emotes[index].height?.toDouble() ?? defaultEmoteSize,
width: emotes[index].width?.toDouble(),
imageUrl: widget.emotes[index].url,
height:
widget.emotes[index].height?.toDouble() ?? defaultEmoteSize,
width: widget.emotes[index].width?.toDouble(),
),
),
),
),
),
itemCount: emotes.length,
itemCount: widget.emotes.length,
);
}
@override
bool get wantKeepAlive => true;
}

View file

@ -38,11 +38,13 @@ class RecentEmotesPanel extends StatelessWidget {
final emote = chatStore.assetsStore.recentEmotes[index];
final validEmotes = [
...chatStore.assetsStore.emoteToObject.values,
...chatStore.assetsStore.userEmoteToObject.values
...chatStore.assetsStore.userEmoteToObject.values,
];
final matchingEmotes = validEmotes.where((existingEmote) =>
final matchingEmotes = validEmotes.where(
(existingEmote) =>
existingEmote.name == emote.name &&
existingEmote.type == emote.type);
existingEmote.type == emote.type,
);
return InkWell(
onTap: matchingEmotes.isNotEmpty
@ -52,7 +54,7 @@ class RecentEmotesPanel extends StatelessWidget {
message: emote.name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(10.0),
padding: const EdgeInsets.all(8),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes.isNotEmpty

View file

@ -109,8 +109,10 @@ abstract class ChatAssetsStoreBase with Store {
_disposeReaction = autorun((_) {
if (_recentEmotes.length > 48) _recentEmotes.removeLast();
prefs.setStringList('recent_emotes',
_recentEmotes.map((emote) => jsonEncode(emote)).toList());
prefs.setStringList(
'recent_emotes',
_recentEmotes.map((emote) => jsonEncode(emote)).toList(),
);
});
}
@ -162,8 +164,11 @@ abstract class ChatAssetsStoreBase with Store {
ffzRoomInfo = roomInfo;
return emotes;
}).catchError(onError),
]).then((assets) => assets.expand((list) => list)).then((emotes) =>
_emoteToObject = {for (final emote in emotes) emote.name: emote});
]).then((assets) => assets.expand((list) => list)).then(
(emotes) => _emoteToObject = {
for (final emote in emotes) emote.name: emote,
},
);
@action
Future<void> badgesFuture({
@ -177,10 +182,12 @@ abstract class ChatAssetsStoreBase with Store {
twitchApi
.getBadgesGlobal(headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.then((_) => twitchApi
.then(
(_) => twitchApi
.getBadgesChannel(id: channelId, headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges))
.catchError(onError)),
.catchError(onError),
),
ffzApi
.getBadges()
.then((badges) => _userToFFZBadges = badges)
@ -201,15 +208,21 @@ abstract class ChatAssetsStoreBase with Store {
required Map<String, String> headers,
required Function onError,
}) async {
final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi
final userEmotes = await Future.wait(
emoteSets.map(
(setId) => twitchApi
.getEmotesSets(setId: setId, headers: headers)
.catchError(onError)));
.catchError(onError),
),
);
for (final emoteSet in userEmotes) {
if (emoteSet.isNotEmpty) {
if (emoteSet.first.type == EmoteType.twitchSub) {
final owner = await twitchApi.getUser(
id: emoteSet.first.ownerId, headers: headers);
id: emoteSet.first.ownerId,
headers: headers,
);
_userEmoteSectionToEmotes.update(
owner.displayName,
(existingEmoteSet) => [...existingEmoteSet, ...emoteSet],

View file

@ -9,7 +9,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.dart';
import 'package:wakelock/wakelock.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
part 'chat_store.g.dart';
@ -80,7 +80,7 @@ abstract class ChatStoreBase with Store {
/// The list of chat messages to add once autoscroll is resumed.
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
final _messageBuffer = <IRCMessage>[];
final messageBuffer = ObservableList<IRCMessage>();
/// Timer used for dismissing the notification.
Timer? _notificationTimer;
@ -88,14 +88,6 @@ abstract class ChatStoreBase with Store {
/// The current timer for the sleep timer if active.
Timer? sleepTimer;
/// The amount of hours the sleep timer is set to.
@observable
var sleepHours = 0;
/// The amount of minutes the sleep timer is set to.
@observable
var sleepMinutes = 0;
/// The time remaining for the sleep timer.
@observable
var timeRemaining = const Duration();
@ -156,7 +148,7 @@ abstract class ChatStoreBase with Store {
required this.displayName,
}) {
// Enable wakelock to prevent the chat from sleeping.
if (settings.chatOnlyPreventSleep) Wakelock.enable();
if (settings.chatOnlyPreventSleep) WakelockPlus.enable();
// Create a reaction that will reconnect to chat when logging in or out.
// Closing the channel will trigger a reconnect with the new credentials.
@ -169,17 +161,22 @@ abstract class ChatStoreBase with Store {
// Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200), (timer) => addMessages());
const Duration(milliseconds: 200),
(timer) => addMessages(),
);
assetsStore.init();
_messageBuffer
messageBuffer
.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
if (settings.chatDelay > 0) {
_messageBuffer.add(IRCMessage.createNotice(
messageBuffer.add(
IRCMessage.createNotice(
message:
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...'));
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...',
),
);
}
connectToChat();
@ -242,8 +239,10 @@ abstract class ChatStoreBase with Store {
if (!_userState.mod &&
channelName != auth.user.details?.login &&
auth.user.blockedUsers
.where((blockedUser) =>
blockedUser.userLogin == parsedIRCMessage.user)
.where(
(blockedUser) =>
blockedUser.userLogin == parsedIRCMessage.user,
)
.isNotEmpty) {
continue;
}
@ -252,19 +251,19 @@ abstract class ChatStoreBase with Store {
case Command.privateMessage:
case Command.notice:
case Command.userNotice:
_messageBuffer.add(parsedIRCMessage);
messageBuffer.add(parsedIRCMessage);
break;
case Command.clearChat:
IRCMessage.clearChat(
messages: _messages,
bufferedMessages: _messageBuffer,
bufferedMessages: messageBuffer,
ircMessage: parsedIRCMessage,
);
break;
case Command.clearMessage:
IRCMessage.clearMessage(
messages: _messages,
bufferedMessages: _messageBuffer,
bufferedMessages: messageBuffer,
ircMessage: parsedIRCMessage,
);
break;
@ -277,7 +276,7 @@ abstract class ChatStoreBase with Store {
if (toSend != null) {
textController.clear();
_messageBuffer.add(toSend!);
messageBuffer.add(toSend!);
toSend = null;
}
break;
@ -301,9 +300,9 @@ abstract class ChatStoreBase with Store {
if (!_autoScroll) {
// While autoscroll is disabled, occasionally move messages from the buffer to the messages to prevent a memory leak.
if (_messageBuffer.length >= _messagesToRemove) {
_messages.addAll(_messageBuffer);
_messageBuffer.clear();
if (messageBuffer.length >= _messagesToRemove) {
_messages.addAll(messageBuffer);
messageBuffer.clear();
}
}
@ -315,9 +314,12 @@ abstract class ChatStoreBase with Store {
_channel?.sink.add('PONG :tmi.twitch.tv');
return;
} else if (message.contains('Welcome, GLHF!')) {
_messageBuffer.add(IRCMessage.createNotice(
messageBuffer.add(
IRCMessage.createNotice(
message:
"Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
"Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!",
),
);
getAssets();
@ -365,8 +367,10 @@ abstract class ChatStoreBase with Store {
// Listen for new messages and forward them to the handler.
_channelListener = _channel?.stream.listen(
(data) => Future.delayed(Duration(seconds: settings.chatDelay.toInt()),
() => _handleIRCData(data.toString())),
(data) => Future.delayed(
Duration(seconds: settings.chatDelay.toInt()),
() => _handleIRCData(data.toString()),
),
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
onDone: () async {
if (_channel == null) return;
@ -375,7 +379,7 @@ abstract class ChatStoreBase with Store {
// Add notice that chat was disconnected and then wait the backoff time before reconnecting.
final notice =
'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...';
_messageBuffer.add(IRCMessage.createNotice(message: notice));
messageBuffer.add(IRCMessage.createNotice(message: notice));
}
await Future.delayed(Duration(seconds: _backoffTime));
@ -385,8 +389,11 @@ abstract class ChatStoreBase with Store {
// Increment the retry count and attempt the reconnect.
_retries++;
_messageBuffer.add(IRCMessage.createNotice(
message: 'Reconnecting to chat (attempt $_retries)...'));
messageBuffer.add(
IRCMessage.createNotice(
message: 'Reconnecting to chat (attempt $_retries)...',
),
);
_channelListener?.cancel();
connectToChat();
},
@ -416,10 +423,10 @@ abstract class ChatStoreBase with Store {
@action
void addMessages() {
if (!_autoScroll || _messageBuffer.isEmpty) return;
if (!_autoScroll || messageBuffer.isEmpty) return;
_messages.addAll(_messageBuffer);
_messageBuffer.clear();
_messages.addAll(messageBuffer);
messageBuffer.clear();
}
/// Sends the given string message by the logged-in user and adds it to [_messages].
@ -429,8 +436,11 @@ abstract class ChatStoreBase with Store {
if (message.isEmpty) return;
if (_channel == null || _channel?.closeCode != null) {
_messageBuffer.add(IRCMessage.createNotice(
message: 'Failed to send message: disconnected from chat.'));
messageBuffer.add(
IRCMessage.createNotice(
message: 'Failed to send message: disconnected from chat.',
),
);
} else {
// Send the message to the IRC chat room.
_channel?.sink.add('PRIVMSG #$channelName :$message');
@ -474,12 +484,15 @@ abstract class ChatStoreBase with Store {
}
assetsStore.recentEmotes
..removeWhere((recentEmote) =>
recentEmote.name == emote.name && recentEmote.type == emote.type)
..removeWhere(
(recentEmote) =>
recentEmote.name == emote.name && recentEmote.type == emote.type,
)
..insert(0, emote);
textController.selection = TextSelection.fromPosition(
TextPosition(offset: textController.text.length));
TextPosition(offset: textController.text.length),
);
}
/// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage].
@ -501,22 +514,18 @@ abstract class ChatStoreBase with Store {
Timer(const Duration(seconds: 2), () => _notification = null);
}
/// Updates the sleep timer with [sleepHours] and [sleepMinutes].
/// Updates the sleep timer with the given [duration].
/// Calls [onTimerFinished] when the sleep timer completes.
@action
void updateSleepTimer({required void Function() onTimerFinished}) {
// If hours and minutes are 0, do nothing.
if (sleepHours == 0 && sleepMinutes == 0) return;
void updateSleepTimer({
required Duration duration,
required VoidCallback onTimerFinished,
}) {
// If there is an ongoing timer, cancel it since it'll be replaced.
if (sleepTimer != null) cancelSleepTimer();
// Update the new time remaining
timeRemaining = Duration(hours: sleepHours, minutes: sleepMinutes);
// Reset the hours and minutes in the dropdown buttons.
sleepHours = 0;
sleepMinutes = 0;
timeRemaining = duration;
// Set a periodic timer that will update the time remaining every second.
sleepTimer = Timer.periodic(
@ -564,6 +573,6 @@ abstract class ChatStoreBase with Store {
chatDetailsStore.dispose();
// Disable wakelock so that the sleep timer will function properly.
Wakelock.disable();
WakelockPlus.disable();
}
}

View file

@ -17,38 +17,6 @@ mixin _$ChatStore on ChatStoreBase, Store {
name: 'ChatStoreBase.renderMessages'))
.value;
late final _$sleepHoursAtom =
Atom(name: 'ChatStoreBase.sleepHours', context: context);
@override
int get sleepHours {
_$sleepHoursAtom.reportRead();
return super.sleepHours;
}
@override
set sleepHours(int value) {
_$sleepHoursAtom.reportWrite(value, super.sleepHours, () {
super.sleepHours = value;
});
}
late final _$sleepMinutesAtom =
Atom(name: 'ChatStoreBase.sleepMinutes', context: context);
@override
int get sleepMinutes {
_$sleepMinutesAtom.reportRead();
return super.sleepMinutes;
}
@override
set sleepMinutes(int value) {
_$sleepMinutesAtom.reportWrite(value, super.sleepMinutes, () {
super.sleepMinutes = value;
});
}
late final _$timeRemainingAtom =
Atom(name: 'ChatStoreBase.timeRemaining', context: context);
@ -316,11 +284,13 @@ mixin _$ChatStore on ChatStoreBase, Store {
}
@override
void updateSleepTimer({required void Function() onTimerFinished}) {
void updateSleepTimer(
{required Duration duration, required VoidCallback onTimerFinished}) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.updateSleepTimer');
try {
return super.updateSleepTimer(onTimerFinished: onTimerFinished);
return super.updateSleepTimer(
duration: duration, onTimerFinished: onTimerFinished);
} finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo);
}
@ -340,8 +310,6 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override
String toString() {
return '''
sleepHours: ${sleepHours},
sleepMinutes: ${sleepMinutes},
timeRemaining: ${timeRemaining},
expandChat: ${expandChat},
renderMessages: ${renderMessages}

View file

@ -3,7 +3,6 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/channel/chat/details/chat_details.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/cached_image.dart';
class ChatBottomBar extends StatelessWidget {
@ -20,9 +19,11 @@ class ChatBottomBar extends StatelessWidget {
color: chatStore.assetsStore.showEmoteMenu
? Theme.of(context).colorScheme.secondary
: null,
icon: Icon(chatStore.assetsStore.showEmoteMenu
icon: Icon(
chatStore.assetsStore.showEmoteMenu
? Icons.emoji_emotions_rounded
: Icons.emoji_emotions_outlined),
: Icons.emoji_emotions_outlined,
),
onPressed: () {
FocusScope.of(context).unfocus();
chatStore.assetsStore.showEmoteMenu =
@ -37,7 +38,7 @@ class ChatBottomBar extends StatelessWidget {
...chatStore.assetsStore.userEmoteToObject.values,
...chatStore.assetsStore.bttvEmotes,
...chatStore.assetsStore.ffzEmotes,
...chatStore.assetsStore.sevenTVEmotes
...chatStore.assetsStore.sevenTVEmotes,
]
.where(
(emote) => emote.name.toLowerCase().contains(
@ -63,24 +64,23 @@ class ChatBottomBar extends StatelessWidget {
if (chatStore.settings.autocomplete &&
chatStore.showEmoteAutocomplete &&
matchingEmotes.isNotEmpty) ...[
const Divider(
height: 1.0,
thickness: 1.0,
),
const Divider(),
SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
padding: const EdgeInsets.all(4),
itemCount: matchingEmotes.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(matchingEmotes[index],
autocompleteMode: true),
onTap: () => chatStore.addEmote(
matchingEmotes[index],
autocompleteMode: true,
),
child: Tooltip(
message: matchingEmotes[index].name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes[index].url,
@ -99,18 +99,17 @@ class ChatBottomBar extends StatelessWidget {
if (chatStore.settings.autocomplete &&
chatStore.showMentionAutocomplete &&
matchingChatters.isNotEmpty) ...[
const Divider(
height: 1.0,
thickness: 1.0,
),
const Divider(),
SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
padding: const EdgeInsets.all(4),
itemCount: matchingChatters.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) => Button(
color: Theme.of(context).colorScheme.secondary,
itemBuilder: (context, index) => TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
),
onPressed: () {
final split = chatStore.textController.text.split(' ')
..removeLast()
@ -118,16 +117,19 @@ class ChatBottomBar extends StatelessWidget {
chatStore.textController.text = split.join(' ');
chatStore.textController.selection =
TextSelection.fromPosition(TextPosition(
offset: chatStore.textController.text.length));
TextSelection.fromPosition(
TextPosition(
offset: chatStore.textController.text.length,
),
);
},
child: Text(matchingChatters[index]),
),
),
)
),
],
Padding(
padding: const EdgeInsets.fromLTRB(10.0, 10.0, 0.0, 10.0),
padding: const EdgeInsets.fromLTRB(12, 12, 0, 12),
child: Row(
children: [
if (!chatStore.expandChat &&
@ -152,7 +154,6 @@ class ChatBottomBar extends StatelessWidget {
maxLines: 5,
enabled: chatStore.auth.isLoggedIn ? true : false,
decoration: InputDecoration(
contentPadding: const EdgeInsets.only(left: 15.0),
prefixIcon: chatStore.settings.emoteMenuButtonOnLeft
? emoteMenuButton
: null,
@ -186,7 +187,6 @@ class ChatBottomBar extends StatelessWidget {
icon: Icon(Icons.adaptive.more_rounded),
tooltip: 'More',
onPressed: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (_) => ChatDetails(

View file

@ -5,6 +5,7 @@ import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage;
@ -29,7 +30,6 @@ class ChatMessage extends StatelessWidget {
ircMessage.user == chatStore.auth.user.details?.login) return;
showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => ChatUserModal(
@ -69,6 +69,7 @@ class ChatMessage extends StatelessWidget {
final messageSpan = Text.rich(
TextSpan(
children: ircMessage.generateSpan(
context,
onTapName: onTapName,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
@ -98,19 +99,26 @@ class ChatMessage extends StatelessWidget {
Icon(
replyUser != null && replyBody != null
? Icons.reply_rounded
: Icons.new_releases_outlined,
: Icons.star_rounded,
size: defaultBadgeSize * chatStore.settings.badgeScale,
color: defaultTextStyle.color?.withOpacity(0.5),
textDirection: TextDirection.rtl,
),
const SizedBox(width: 5.0),
const SizedBox(width: 4),
Flexible(
child: replyUser != null && replyBody != null
? Tooltip(
message: 'Replying to @$replyUser: $replyBody',
preferBelow: false,
triggerMode: TooltipTriggerMode.tap,
showDuration: const Duration(seconds: 5),
? GestureDetector(
onTap: isModal
? null
: () => showModalBottomSheet(
context: context,
builder: (context) {
return ReplyThread(
selectedMessage: ircMessage,
chatStore: chatStore,
);
},
),
child: Text(
'Replying to @$replyUser: $replyBody',
maxLines: 1,
@ -124,7 +132,7 @@ class ChatMessage extends StatelessWidget {
: Text(
'First time chatting',
style: TextStyle(
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
color:
defaultTextStyle.color?.withOpacity(0.5),
),
@ -132,7 +140,7 @@ class ChatMessage extends StatelessWidget {
),
],
),
const SizedBox(height: 5.0),
const SizedBox(height: 4),
messageSpan,
],
);
@ -155,22 +163,23 @@ class ChatMessage extends StatelessWidget {
if (ircMessage.command == Command.clearMessage)
const Text(
'Message deleted',
style: TextStyle(fontWeight: FontWeight.w600),
style: TextStyle(fontWeight: FontWeight.w500),
)
else
const Text(
'Permanently banned',
style: TextStyle(fontWeight: FontWeight.w600),
style: TextStyle(fontWeight: FontWeight.w500),
)
else
Text(
'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}',
style: const TextStyle(fontWeight: FontWeight.w600),
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 5.0),
const SizedBox(height: 4),
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
context,
onTapName: onTapName,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
@ -206,8 +215,9 @@ class ChatMessage extends StatelessWidget {
Text(
ircMessage.tags['system-msg']!,
style: TextStyle(
fontWeight: FontWeight.w600,
color: defaultTextStyle.color?.withOpacity(0.5)),
fontWeight: FontWeight.w500,
color: defaultTextStyle.color?.withOpacity(0.5),
),
),
if (ircMessage.tags['msg-id'] == 'announcement')
Row(
@ -217,18 +227,19 @@ class ChatMessage extends StatelessWidget {
size:
defaultBadgeSize * chatStore.settings.badgeScale,
),
const SizedBox(width: 5.0),
const SizedBox(width: 4),
const Text(
'Announcement',
style: TextStyle(fontWeight: FontWeight.w600),
style: TextStyle(fontWeight: FontWeight.w500),
),
],
),
const SizedBox(height: 5.0),
const SizedBox(height: 4),
if (ircMessage.message != null)
Text.rich(
TextSpan(
children: ircMessage.generateSpan(
context,
onTapName: onTapName,
style: defaultTextStyle,
assetsStore: chatStore.assetsStore,
@ -256,7 +267,8 @@ class ChatMessage extends StatelessWidget {
final paddedMessage = Padding(
padding: EdgeInsets.symmetric(
vertical: chatStore.settings.messageSpacing / 2,
horizontal: 10.0),
horizontal: 12,
),
child: renderMessage,
);
@ -266,10 +278,7 @@ class ChatMessage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
paddedMessage,
const Divider(
height: 1.0,
thickness: 1.0,
),
const Divider(),
],
)
: paddedMessage;

View file

@ -5,10 +5,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/section_header.dart';
class ChatUserModal extends StatefulWidget {
final ChatStore chatStore;
@ -35,14 +32,13 @@ class _ChatUserModalState extends State<ChatUserModal> {
? widget.displayName
: '${widget.displayName} (${widget.username})';
return FrostyBottomSheet(
child: SizedBox(
return SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: const EdgeInsets.all(10.0),
contentPadding: const EdgeInsets.all(12),
leading: ProfilePicture(
userLogin: widget.username,
),
@ -60,20 +56,23 @@ class _ChatUserModalState extends State<ChatUserModal> {
),
],
),
trailing: widget.chatStore.auth.isLoggedIn
? Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.chatStore.auth.isLoggedIn)
IconButton(
tooltip: 'Reply',
onPressed: () {
widget.chatStore.textController.text =
'@${widget.username} ';
Navigator.pop(context);
widget.chatStore.textFieldFocusNode.requestFocus();
},
child: const Text('Reply'),
)
: null,
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
icon: const Icon(Icons.reply_rounded),
),
IconButton(
tooltip: 'More',
onPressed: () => showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
authStore: widget.chatStore.auth,
@ -82,10 +81,10 @@ class _ChatUserModalState extends State<ChatUserModal> {
userId: widget.userId,
),
),
icon: Icon(Icons.adaptive.more_rounded),
),
],
),
const SectionHeader(
'Recent messages',
padding: EdgeInsets.all(10.0),
),
Expanded(
child: Observer(
@ -100,13 +99,15 @@ class _ChatUserModalState extends State<ChatUserModal> {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaleFactor:
widget.chatStore.settings.messageScale),
textScaleFactor: widget.chatStore.settings.messageScale,
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
fontSize: widget.chatStore.settings.fontSize),
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: widget.chatStore.settings.fontSize),
child: ListView.builder(
reverse: true,
primary: false,
itemCount: userMessages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage: userMessages[index],
@ -118,9 +119,8 @@ class _ChatUserModalState extends State<ChatUserModal> {
);
},
),
)
],
),
],
),
);
}

View file

@ -0,0 +1,89 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/section_header.dart';
class ReplyThread extends StatelessWidget {
final IRCMessage selectedMessage;
final ChatStore chatStore;
const ReplyThread({
super.key,
required this.selectedMessage,
required this.chatStore,
});
@override
Widget build(BuildContext context) {
final replyParent = chatStore.messages.firstWhereOrNull(
(message) =>
message.tags['id'] == selectedMessage.tags['reply-parent-msg-id'],
);
final replyDisplayName = selectedMessage.tags['reply-parent-display-name'];
final replyUserLogin = selectedMessage.tags['reply-parent-user-login'];
final replyBody = selectedMessage.tags['reply-parent-msg-body'];
final replyName = regexEnglish.hasMatch(replyDisplayName!)
? replyDisplayName
: '$replyDisplayName ($replyUserLogin)';
return Observer(
builder: (context) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaleFactor: chatStore.settings.messageScale,
),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: chatStore.settings.fontSize),
child: ListView(
shrinkWrap: true,
primary: false,
children: [
const SectionHeader(
'Reply thread',
padding: EdgeInsets.fromLTRB(12, 0, 12, 8),
),
if (replyParent != null)
ChatMessage(
isModal: true,
ircMessage: replyParent,
chatStore: chatStore,
)
else
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Text(
'Replies to @$replyName: $replyBody',
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
),
...chatStore.messages
.where(
(message) =>
message.tags['reply-parent-msg-id'] ==
selectedMessage.tags['reply-parent-msg-id'],
)
.map(
(message) => ChatMessage(
isModal: true,
ircMessage: message,
chatStore: chatStore,
),
),
],
),
),
);
},
);
}
}

View file

@ -31,7 +31,8 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
@override
Future<void> didChangeAppLifecycleState(
AppLifecycleState lifecycleState) async {
AppLifecycleState lifecycleState,
) async {
if (Platform.isAndroid &&
!await SimplePip.isAutoPipAvailable &&
lifecycleState == AppLifecycleState.inactive) {

View file

@ -11,6 +11,7 @@ class VideoBar extends StatelessWidget {
final Color? titleTextColor;
final Color? subtitleTextColor;
final FontWeight? subtitleTextWeight;
final EdgeInsets padding;
const VideoBar({
Key? key,
@ -20,6 +21,7 @@ class VideoBar extends StatelessWidget {
this.titleTextColor,
this.subtitleTextColor,
this.subtitleTextWeight,
this.padding = const EdgeInsets.all(12),
}) : super(key: key);
@override
@ -33,12 +35,14 @@ class VideoBar extends StatelessWidget {
: '${streamInfo.userName} (${streamInfo.userLogin})';
return Padding(
padding: const EdgeInsets.all(10),
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePicture(userLogin: streamInfo.userLogin),
const SizedBox(width: 10.0),
ProfilePicture(
userLogin: streamInfo.userLogin,
radius: 28,
),
const SizedBox(width: 12),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
@ -57,7 +61,7 @@ class VideoBar extends StatelessWidget {
),
),
),
const SizedBox(height: 5.0),
const SizedBox(height: 2),
Tooltip(
message: 'Title: ${streamInfo.title.trim()}',
showDuration: const Duration(seconds: 5),
@ -75,14 +79,13 @@ class VideoBar extends StatelessWidget {
),
),
if (showCategory) ...[
const SizedBox(height: 5.0),
const SizedBox(height: 2),
InkWell(
onTap: tappableCategory && streamInfo.gameName.isNotEmpty
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryName: streamInfo.gameName,
categoryId: streamInfo.gameId,
),
),

View file

@ -5,7 +5,6 @@ import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/video_store.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
import 'package:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart';
@ -94,7 +93,8 @@ class VideoOverlay extends StatelessWidget {
]);
} else {
SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp]);
[DeviceOrientation.portraitUp],
);
SystemChrome.setPreferredOrientations([]);
}
},
@ -157,7 +157,7 @@ class VideoOverlay extends StatelessWidget {
message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false,
child: IconButton(
iconSize: 50.0,
iconSize: 56,
icon: Icon(
videoStore.paused
? Icons.play_arrow_rounded
@ -175,7 +175,7 @@ class VideoOverlay extends StatelessWidget {
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0, bottom: 12.0),
padding: const EdgeInsets.all(12),
child: Row(
children: [
Tooltip(
@ -183,15 +183,12 @@ class VideoOverlay extends StatelessWidget {
preferBelow: false,
child: Row(
children: [
const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
const Icon(
Icons.circle,
color: Colors.red,
size: 10,
),
),
const SizedBox(width: 3.0),
const SizedBox(width: 4),
Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
@ -202,19 +199,17 @@ class VideoOverlay extends StatelessWidget {
],
),
),
const SizedBox(width: 10),
const SizedBox(width: 12),
Tooltip(
message: 'Viewer count',
preferBelow: false,
child: GestureDetector(
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyBottomSheet(
child: SizedBox(
height: MediaQuery.of(context).size.height *
0.8,
builder: (context) => SizedBox(
height:
MediaQuery.of(context).size.height * 0.8,
child: GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: ChattersList(
@ -226,7 +221,6 @@ class VideoOverlay extends StatelessWidget {
),
),
),
),
child: Row(
children: [
const Icon(
@ -234,10 +228,11 @@ class VideoOverlay extends StatelessWidget {
size: 14,
color: Colors.white,
),
const SizedBox(width: 5),
const SizedBox(width: 4),
Text(
NumberFormat().format(
videoStore.streamInfo?.viewerCount),
videoStore.streamInfo?.viewerCount,
),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
@ -267,7 +262,7 @@ class VideoOverlay extends StatelessWidget {
if (orientation == Orientation.landscape) fullScreenButton,
],
),
)
),
],
);
},

View file

@ -39,18 +39,26 @@ abstract class VideoStoreBase with Store {
WebViewController.fromPlatformCreationParams(_videoWebViewParams)
..setBackgroundColor(Colors.black)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('VideoPause', onMessageReceived: (message) {
..addJavaScriptChannel(
'VideoPause',
onMessageReceived: (message) {
_paused = true;
if (Platform.isAndroid) pip.setIsPlaying(false);
})
..addJavaScriptChannel('VideoPlaying', onMessageReceived: (message) {
},
)
..addJavaScriptChannel(
'VideoPlaying',
onMessageReceived: (message) {
_paused = false;
if (Platform.isAndroid) pip.setIsPlaying(true);
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].muted = false;');
'document.getElementsByTagName("video")[0].muted = false;',
);
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].volume = 1.0;');
})
'document.getElementsByTagName("video")[0].volume = 1.0;',
);
},
)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (_) => initVideo(),
@ -136,9 +144,11 @@ abstract class VideoStoreBase with Store {
// Add event listeners to notify the JavaScript channels when the video plays and pauses.
try {
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));');
'document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));',
);
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));');
'document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));',
);
} catch (e) {
debugPrint(e.toString());
}
@ -150,7 +160,7 @@ abstract class VideoStoreBase with Store {
if (Platform.isIOS) {
final deviceInfo = DeviceInfoPlugin();
final info = await deviceInfo.iosInfo;
if (info.model?.toLowerCase().contains('ipad') == true) {
if (info.model.toLowerCase().contains('ipad')) {
_isIPad = true;
} else {
_isIPad = false;
@ -181,7 +191,9 @@ abstract class VideoStoreBase with Store {
Future<void> updateStreamInfo() async {
try {
_streamInfo = await twitchApi.getStream(
userLogin: userLogin, headers: authStore.headersTwitch);
userLogin: userLogin,
headers: authStore.headersTwitch,
);
} catch (e) {
debugPrint(e.toString());
@ -227,7 +239,8 @@ abstract class VideoStoreBase with Store {
.runJavaScript('document.getElementsByTagName("video")[0].play();');
} else {
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].pause();');
'document.getElementsByTagName("video")[0].pause();',
);
}
} catch (e) {
debugPrint(e.toString());
@ -244,7 +257,8 @@ abstract class VideoStoreBase with Store {
pip.enterPipMode(autoEnter: true);
} else if (Platform.isIOS) {
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].requestPictureInPicture();');
'document.getElementsByTagName("video")[0].requestPictureInPicture();',
);
}
} catch (e) {
debugPrint(e.toString());

View file

@ -9,6 +9,7 @@ import 'package:frosty/screens/home/top/top.dart';
import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:provider/provider.dart';
class Home extends StatefulWidget {
@ -27,6 +28,8 @@ class _HomeState extends State<Home> {
Widget build(BuildContext context) {
SystemChrome.setPreferredOrientations([]);
final isLoggedIn = _authStore.isLoggedIn && _authStore.user.details != null;
return GestureDetector(
onTap: FocusScope.of(context).unfocus,
child: Scaffold(
@ -46,7 +49,12 @@ class _HomeState extends State<Home> {
actions: [
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_rounded),
icon: isLoggedIn
? ProfilePicture(
userLogin: _authStore.user.details!.login,
radius: 16,
)
: const Icon(Icons.settings_rounded),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
@ -54,7 +62,7 @@ class _HomeState extends State<Home> {
Settings(settingsStore: context.read<SettingsStore>()),
),
),
)
),
],
),
body: SafeArea(
@ -78,33 +86,29 @@ class _HomeState extends State<Home> {
),
),
bottomNavigationBar: Observer(
builder: (_) => BottomNavigationBar(
selectedFontSize: 12.0,
type: BottomNavigationBarType.fixed,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),
items: [
builder: (_) => NavigationBar(
destinations: [
if (_authStore.isLoggedIn)
BottomNavigationBarItem(
NavigationDestination(
icon: _homeStore.selectedIndex == 0
? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_border_rounded),
label: 'Following',
tooltip: 'Followed streams',
),
const BottomNavigationBarItem(
const NavigationDestination(
icon: Icon(Icons.arrow_upward_rounded),
label: 'Top',
tooltip: 'Top streams and categories',
),
const BottomNavigationBarItem(
const NavigationDestination(
icon: Icon(Icons.search_rounded),
label: 'Search',
tooltip: 'Search for channels and categories',
),
],
currentIndex: _homeStore.selectedIndex,
onTap: _homeStore.handleTap,
selectedIndex: _homeStore.selectedIndex,
onDestinationSelected: _homeStore.handleTap,
),
),
),

View file

@ -47,7 +47,7 @@ abstract class HomeStoreBase with Store {
if (index != _selectedIndex) {
_selectedIndex = index;
} else {
const duration = Duration(milliseconds: 500);
const duration = Duration(milliseconds: 300);
// Use different logic if logged in/out since there will be one less tab when logged out.
if (authStore.isLoggedIn) {

View file

@ -7,7 +7,6 @@ import 'package:frosty/screens/home/search/search_results_channels.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart';
@ -33,20 +32,17 @@ class _SearchState extends State<Search> {
@override
Widget build(BuildContext context) {
const headerPadding = EdgeInsets.fromLTRB(15.0, 20.0, 15.0, 10.0);
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: Observer(
Observer(
builder: (context) {
return TextField(
return Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _searchStore.textEditingController,
focusNode: _searchStore.textFieldFocusNode,
autocorrect: false,
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Find a channel or category',
suffixIcon: _searchStore.textFieldFocusNode.hasFocus ||
@ -66,11 +62,13 @@ class _SearchState extends State<Search> {
: null,
),
onSubmitted: _searchStore.handleQuery,
),
);
},
),
),
Expanded(
child: Scrollbar(
controller: widget.scrollController,
child: Observer(
builder: (context) {
if (_searchStore.textEditingController.text.isEmpty) {
@ -80,38 +78,42 @@ class _SearchState extends State<Search> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, right: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SectionHeader(
'History',
padding: headerPadding,
padding: EdgeInsets.zero,
),
TextButton(
onPressed: _searchStore.searchHistory.clear,
child: const Text('Clear'),
),
],
),
),
Expanded(
child: ListView(
controller: widget.scrollController,
children: _searchStore.searchHistory
.mapIndexed(
(index, searchTerm) => FrostyListTile(
(index, searchTerm) => ListTile(
leading: const Icon(Icons.history_rounded),
title: searchTerm,
trailing: Tooltip(
message: 'Remove',
preferBelow: false,
child: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => _searchStore.searchHistory
.removeAt(index),
),
),
title: Text(searchTerm),
onTap: () {
_searchStore.textEditingController.text =
searchTerm;
_searchStore.handleQuery(searchTerm);
_searchStore.textEditingController.selection =
TextSelection.fromPosition(TextPosition(
_searchStore.textEditingController
.selection = TextSelection.fromPosition(
TextPosition(
offset: _searchStore
.textEditingController
.text
.length));
.textEditingController.text.length,
),
);
},
),
)
@ -127,7 +129,7 @@ class _SearchState extends State<Search> {
const SliverToBoxAdapter(
child: SectionHeader(
'Channels',
padding: headerPadding,
padding: EdgeInsets.fromLTRB(16, 12, 16, 8),
),
),
SearchResultsChannels(
@ -137,7 +139,7 @@ class _SearchState extends State<Search> {
const SliverToBoxAdapter(
child: SectionHeader(
'Categories',
padding: headerPadding,
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
),
SearchResultsCategories(searchStore: _searchStore),
@ -146,6 +148,7 @@ class _SearchState extends State<Search> {
},
),
),
),
],
);
}

View file

@ -57,18 +57,11 @@ class SearchResultsCategories extends StatelessWidget {
);
}
return SliverGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 20,
children: categories.data
.map(
(category) => GridTile(
child: CategoryCard(
category: category,
return SliverList.builder(
itemCount: categories.data.length,
itemBuilder: (context, index) => CategoryCard(
category: categories.data[index],
),
),
)
.toList(),
);
}
},

View file

@ -6,9 +6,7 @@ import 'package:frosty/models/channel.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart';
@ -46,8 +44,10 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
);
} catch (error) {
final snackBar = SnackBar(
content: AlertMessage(message: error.toString()),
behavior: SnackBarBehavior.floating,
content: AlertMessage(
message: error.toString(),
centered: false,
),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
@ -78,7 +78,8 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
final results = (future.result as List<ChannelQuery>).where(
(channel) => !widget.searchStore.authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId)
.contains(channel.id));
.contains(channel.id),
);
return SliverList(
delegate: SliverChildListDelegate.fixed(
@ -90,7 +91,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
? channel.displayName
: '${channel.displayName} (${channel.broadcasterLogin})';
return AnimateScale(
return InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
@ -105,7 +106,6 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
HapticFeedback.lightImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: widget.searchStore.authStore,
@ -115,11 +115,12 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
),
);
},
child: FrostyListTile(
isThreeLine: false,
title: displayName,
child: ListTile(
title: Text(displayName),
leading: ProfilePicture(
userLogin: channel.broadcasterLogin),
userLogin: channel.broadcasterLogin,
radius: 16,
),
subtitle: channel.isLive
? Row(
children: [
@ -137,11 +138,11 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
);
},
),
FrostyListTile(
title: 'Go to channel "${widget.query}"',
ListTile(
title: Text('Go to channel "${widget.query}"'),
onTap: () => _handleSearch(context, widget.query),
trailing: const Icon(Icons.chevron_right_rounded),
)
),
],
),
);

View file

@ -90,9 +90,13 @@ abstract class SearchStoreBase with Store {
/// This is used for channels that may not show up in the search results.
Future<Channel> searchChannel(String query) async {
final user = await twitchApi.getUser(
userLogin: query, headers: authStore.headersTwitch);
userLogin: query,
headers: authStore.headersTwitch,
);
return await twitchApi.getChannel(
userId: user.id, headers: authStore.headersTwitch);
userId: user.id,
headers: authStore.headersTwitch,
);
}
void dispose() {

View file

@ -3,12 +3,10 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
@ -45,7 +43,7 @@ class LargeStreamCard extends StatelessWidget {
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
final thumbnail = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Stack(
alignment: Alignment.bottomLeft,
children: [
@ -66,16 +64,19 @@ class LargeStreamCard extends StatelessWidget {
child: FrostyCachedNetworkImage(
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
'-{width}x{height}',
'-${thumbnailWidth}x$thumbnailHeight') +
'-${thumbnailWidth}x$thumbnailHeight',
) +
cacheUrlExtension,
placeholder: (context, url) => const ColoredBox(
color: lightGray, child: LoadingIndicator()),
placeholder: (context, url) => ColoredBox(
color: Colors.grey.shade900,
child: const LoadingIndicator(),
),
useOldImageOnUrlChange: true,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 10.0, bottom: 12.0),
padding: const EdgeInsets.all(12),
child: Row(
children: [
Tooltip(
@ -83,15 +84,12 @@ class LargeStreamCard extends StatelessWidget {
preferBelow: false,
child: Row(
children: [
const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
const Icon(
Icons.circle,
color: Colors.red,
size: 10,
),
),
const SizedBox(width: 3.0),
const SizedBox(width: 4),
Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
@ -102,7 +100,7 @@ class LargeStreamCard extends StatelessWidget {
],
),
),
const SizedBox(width: 10.0),
const SizedBox(width: 12),
Tooltip(
message: 'Viewer count',
preferBelow: false,
@ -113,7 +111,7 @@ class LargeStreamCard extends StatelessWidget {
size: 14,
color: Colors.white,
),
const SizedBox(width: 5),
const SizedBox(width: 4),
Text(
NumberFormat().format(streamInfo.viewerCount),
style: const TextStyle(
@ -123,7 +121,7 @@ class LargeStreamCard extends StatelessWidget {
),
],
),
)
),
],
),
),
@ -135,7 +133,7 @@ class LargeStreamCard extends StatelessWidget {
? streamInfo.userName
: '${streamInfo.userName} (${streamInfo.userLogin})';
return AnimateScale(
return InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
@ -150,7 +148,6 @@ class LargeStreamCard extends StatelessWidget {
HapticFeedback.mediumImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: context.read<AuthStore>(),
@ -162,13 +159,16 @@ class LargeStreamCard extends StatelessWidget {
},
child: Padding(
padding: EdgeInsets.symmetric(
vertical: showThumbnail ? 10.0 : 5.0, horizontal: 15.0),
vertical: showThumbnail ? 12 : 4,
horizontal: 16,
),
child: Column(
children: [
if (showThumbnail) thumbnail,
VideoBar(
streamInfo: streamInfo,
showCategory: showCategory,
padding: const EdgeInsets.symmetric(vertical: 8),
),
],
),

View file

@ -3,12 +3,10 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/animate_scale.dart';
import 'package:frosty/widgets/block_report_modal.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
@ -50,10 +48,14 @@ class StreamCard extends StatelessWidget {
aspectRatio: 16 / 9,
child: FrostyCachedNetworkImage(
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
'-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') +
'-{width}x{height}',
'-${thumbnailWidth}x$thumbnailHeight',
) +
cacheUrlExtension,
placeholder: (context, url) =>
const ColoredBox(color: lightGray, child: LoadingIndicator()),
placeholder: (context, url) => ColoredBox(
color: Colors.grey.shade900,
child: const LoadingIndicator(),
),
useOldImageOnUrlChange: true,
),
);
@ -67,20 +69,20 @@ class StreamCard extends StatelessWidget {
final fontColor = DefaultTextStyle.of(context).style.color;
final imageSection = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(5.0)),
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
thumbnail,
Container(
padding: const EdgeInsets.all(2.0),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.5),
borderRadius: BorderRadius.all(
Radius.circular(3.0),
Radius.circular(6),
),
),
margin: const EdgeInsets.all(2.0),
margin: const EdgeInsets.all(4),
child: Uptime(
startTime: streamInfo.startedAt,
style: const TextStyle(
@ -89,12 +91,13 @@ class StreamCard extends StatelessWidget {
color: Colors.white,
),
),
)
),
],
));
),
);
final streamInfoSection = Padding(
padding: const EdgeInsets.only(left: 10.0),
padding: const EdgeInsets.only(left: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -102,9 +105,9 @@ class StreamCard extends StatelessWidget {
children: [
ProfilePicture(
userLogin: streamInfo.userLogin,
radius: 10.0,
radius: 10,
),
const SizedBox(width: 5.0),
const SizedBox(width: 4),
Flexible(
child: Tooltip(
message: 'Streamer: $streamerName',
@ -113,7 +116,7 @@ class StreamCard extends StatelessWidget {
streamerName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.0,
fontSize: 16,
fontWeight: FontWeight.w600,
color: fontColor,
),
@ -122,11 +125,10 @@ class StreamCard extends StatelessWidget {
),
],
),
const SizedBox(height: 5.0),
const SizedBox(height: 2),
Tooltip(
message: 'Title: ${streamInfo.title.trim()}',
preferBelow: false,
padding: const EdgeInsets.all(10.0),
child: Text(
streamInfo.title.trim(),
overflow: TextOverflow.ellipsis,
@ -136,7 +138,7 @@ class StreamCard extends StatelessWidget {
),
),
),
const SizedBox(height: 5.0),
const SizedBox(height: 2),
if (showCategory) ...[
InkWell(
onTap: streamInfo.gameName.isNotEmpty
@ -144,7 +146,6 @@ class StreamCard extends StatelessWidget {
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryName: streamInfo.gameName,
categoryId: streamInfo.gameId,
),
),
@ -166,7 +167,7 @@ class StreamCard extends StatelessWidget {
),
),
),
const SizedBox(height: 5.0),
const SizedBox(height: 2),
],
Text(
'${NumberFormat().format(streamInfo.viewerCount)} viewers',
@ -179,7 +180,7 @@ class StreamCard extends StatelessWidget {
),
);
return AnimateScale(
return InkWell(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
@ -194,7 +195,6 @@ class StreamCard extends StatelessWidget {
HapticFeedback.mediumImpact();
showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: context.read<AuthStore>(),
@ -206,7 +206,9 @@ class StreamCard extends StatelessWidget {
},
child: Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0, horizontal: showThumbnail ? 15.0 : 5.0),
vertical: 8,
horizontal: showThumbnail ? 16 : 4,
),
child: Row(
children: [
if (showThumbnail)

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:mobx/mobx.dart';
@ -45,6 +46,9 @@ abstract class ListStoreBase with Store {
@readonly
var _allStreams = ObservableList<StreamTwitch>();
@readonly
CategoryTwitch? _categoryDetails;
/// Whether or not the scroll to top button is visible.
@observable
var showJumpButton = false;
@ -52,9 +56,11 @@ abstract class ListStoreBase with Store {
/// The list of the fetched streams with blocked users filtered out.
@computed
ObservableList<StreamTwitch> get streams => _allStreams
.where((streamInfo) => !authStore.user.blockedUsers
.where(
(streamInfo) => !authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId)
.contains(streamInfo.userId))
.contains(streamInfo.userId),
)
.toList()
.asObservable();
@ -81,6 +87,10 @@ abstract class ListStoreBase with Store {
}
getStreams();
if (listType == ListType.category && categoryId != null) {
_getCategoryDetails();
}
}
/// Fetches the streams based on the type and current cursor.
@ -138,6 +148,20 @@ abstract class ListStoreBase with Store {
return getStreams();
}
@action
Future<void> _getCategoryDetails() async {
_isLoading = true;
final categoryDetails = await twitchApi.getCategory(
headers: authStore.headersTwitch,
gameId: categoryId!,
);
_categoryDetails = categoryDetails.data.first;
_isLoading = false;
}
/// Checks the last time the streams were refreshed and updates them if it has been more than 5 minutes.
void checkLastTimeRefreshedAndUpdate() {
final now = DateTime.now();

View file

@ -59,6 +59,24 @@ mixin _$ListStore on ListStoreBase, Store {
});
}
late final _$_categoryDetailsAtom =
Atom(name: 'ListStoreBase._categoryDetails', context: context);
CategoryTwitch? get categoryDetails {
_$_categoryDetailsAtom.reportRead();
return super._categoryDetails;
}
@override
CategoryTwitch? get _categoryDetails => categoryDetails;
@override
set _categoryDetails(CategoryTwitch? value) {
_$_categoryDetailsAtom.reportWrite(value, super._categoryDetails, () {
super._categoryDetails = value;
});
}
late final _$showJumpButtonAtom =
Atom(name: 'ListStoreBase.showJumpButton', context: context);
@ -101,6 +119,15 @@ mixin _$ListStore on ListStoreBase, Store {
return _$getStreamsAsyncAction.run(() => super.getStreams());
}
late final _$_getCategoryDetailsAsyncAction =
AsyncAction('ListStoreBase._getCategoryDetails', context: context);
@override
Future<void> _getCategoryDetails() {
return _$_getCategoryDetailsAsyncAction
.run(() => super._getCategoryDetails());
}
late final _$ListStoreBaseActionController =
ActionController(name: 'ListStoreBase', context: context);

View file

@ -5,10 +5,12 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/top/categories/category_card.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:provider/provider.dart';
/// A widget that displays a list of followed or top streams based on the provided [listType].
@ -17,13 +19,19 @@ class StreamsList extends StatefulWidget {
/// The type of list to display.
final ListType listType;
final String? categoryId;
/// The scroll controller to use for scroll to top functionality.
final ScrollController scrollController;
final ScrollController? scrollController;
final bool showJumpButton;
const StreamsList({
Key? key,
required this.listType,
required this.scrollController,
this.categoryId,
this.scrollController,
this.showJumpButton = false,
}) : super(key: key);
@override
@ -36,6 +44,9 @@ class _StreamsListState extends State<StreamsList>
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
listType: widget.listType,
categoryId: widget.categoryId,
scrollController: widget.scrollController ??
(widget.showJumpButton ? ScrollController() : null),
);
@override
@ -62,7 +73,7 @@ class _StreamsListState extends State<StreamsList>
_listStore.checkLastTimeRefreshedAndUpdate();
return RefreshIndicator(
return RefreshIndicator.adaptive(
onRefresh: () async {
HapticFeedback.lightImpact();
@ -70,8 +81,10 @@ class _StreamsListState extends State<StreamsList>
if (_listStore.error != null) {
final snackBar = SnackBar(
content: AlertMessage(message: _listStore.error!),
behavior: SnackBarBehavior.floating,
content: AlertMessage(
message: _listStore.error!,
centered: false,
),
);
if (!mounted) return;
@ -94,7 +107,8 @@ class _StreamsListState extends State<StreamsList>
statusWidget = AlertMessage(
message: widget.listType == ListType.followed
? 'No followed streams'
: 'No top streams');
: 'No top streams',
);
}
}
@ -105,14 +119,28 @@ class _StreamsListState extends State<StreamsList>
child: Center(
child: statusWidget,
),
)
),
],
);
}
return ListView.builder(
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
Column(
children: [
if (widget.categoryId != null &&
_listStore.categoryDetails != null)
CategoryCard(
category: _listStore.categoryDetails!,
isTappable: false,
),
Expanded(
child: Scrollbar(
controller: _listStore.scrollController,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.scrollController,
controller: _listStore.scrollController,
itemCount: _listStore.streams.length,
itemBuilder: (context, index) {
if (index > _listStore.streams.length - 10 &&
@ -125,19 +153,46 @@ class _StreamsListState extends State<StreamsList>
builder: (context) =>
context.read<SettingsStore>().largeStreamCard
? LargeStreamCard(
key: ValueKey(_listStore.streams[index].userId),
key: ValueKey(
_listStore.streams[index].userId,
),
streamInfo: _listStore.streams[index],
showThumbnail:
context.read<SettingsStore>().showThumbnails,
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory: widget.categoryId == null,
)
: StreamCard(
key: ValueKey(_listStore.streams[index].userId),
key: ValueKey(
_listStore.streams[index].userId,
),
streamInfo: _listStore.streams[index],
showThumbnail:
context.read<SettingsStore>().showThumbnails,
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory: widget.categoryId == null,
),
);
},
),
),
),
],
),
if (widget.showJumpButton)
Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _listStore.showJumpButton
? ScrollToTopButton(
scrollController: _listStore.scrollController!,
)
: null,
),
),
],
);
},
),

View file

@ -52,15 +52,17 @@ class _CategoriesState extends State<Categories>
_categoriesStore.checkLastTimeRefreshedAndUpdate();
return RefreshIndicator(
return RefreshIndicator.adaptive(
onRefresh: () async {
HapticFeedback.lightImpact();
await _categoriesStore.refreshCategories();
if (_categoriesStore.error != null) {
final snackBar = SnackBar(
content: AlertMessage(message: _categoriesStore.error!),
behavior: SnackBarBehavior.floating,
content: AlertMessage(
message: _categoriesStore.error!,
centered: false,
),
);
if (!mounted) return;
@ -91,17 +93,16 @@ class _CategoriesState extends State<Categories>
child: Center(
child: statusWidget,
),
)
),
],
);
}
return GridView.builder(
return Scrollbar(
controller: widget.scrollController,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: widget.scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
itemCount: _categoriesStore.categories.length,
itemBuilder: (context, index) {
if (index > _categoriesStore.categories.length - 10 &&
@ -113,6 +114,7 @@ class _CategoriesState extends State<Categories>
category: _categoriesStore.categories[index],
);
},
),
);
},
),

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/top/categories/category_streams.dart';
import 'package:frosty/widgets/cached_image.dart';
@ -8,10 +7,12 @@ import 'package:frosty/widgets/loading_indicator.dart';
/// A tappable card widget that displays a category's box art and name under.
class CategoryCard extends StatelessWidget {
final CategoryTwitch category;
final bool isTappable;
const CategoryCard({
Key? key,
required this.category,
this.isTappable = true,
}) : super(key: key);
@override
@ -19,47 +20,54 @@ class CategoryCard extends StatelessWidget {
// Calculate the dimmensions of the box art based on the current dimmensions of the screen.
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final artWidth = (size.width * pixelRatio) ~/ 3;
final artWidth = (size.width * pixelRatio) ~/ 5;
final artHeight = (artWidth * (4 / 3)).toInt();
return InkWell(
onTap: () => Navigator.push(
onTap: isTappable
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategoryStreams(
categoryName: category.name,
categoryId: category.id,
),
),
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0),
child: Column(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
SizedBox(
width: 80,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 3 / 4,
child: FrostyCachedNetworkImage(
imageUrl: category.boxArtUrl.replaceRange(
category.boxArtUrl.lastIndexOf('-') + 1,
null,
'${artWidth}x$artHeight.jpg'),
placeholder: (context, url) => const ColoredBox(
color: lightGray, child: LoadingIndicator()),
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) => ColoredBox(
color: Colors.grey.shade900,
child: const LoadingIndicator(),
),
),
),
),
const SizedBox(height: 5.0),
),
const SizedBox(width: 16),
Tooltip(
message: category.name,
preferBelow: false,
padding: const EdgeInsets.all(10.0),
child: Text(
category.name,
style: const TextStyle(fontWeight: FontWeight.w600),
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),

View file

@ -1,218 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_card.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/cached_image.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:provider/provider.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/widgets/app_bar.dart';
/// A widget that displays a list of streams under the provided [categoryId].
/// This differs from the normal [StreamsList] in that it uses slivers to display the box art on top.
class CategoryStreams extends StatefulWidget {
/// The category name, used for the header on the box art sliver.
final String categoryName;
class CategoryStreams extends StatelessWidget {
/// The category id, used for fetching the relevant streams in the [ListStore].
final String categoryId;
const CategoryStreams({
Key? key,
required this.categoryName,
required this.categoryId,
}) : super(key: key);
@override
State<CategoryStreams> createState() => _CategoryStreamsState();
}
class _CategoryStreamsState extends State<CategoryStreams> {
late final _listStore = ListStore(
authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(),
listType: ListType.category,
categoryId: widget.categoryId,
scrollController: ScrollController(),
);
@override
Widget build(BuildContext context) {
// Calculate the dimensions of the box art based on the screen width.
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final artWidth = (size.width * pixelRatio).toInt();
final artHeight = (artWidth * (4 / 3)).toInt();
return Scaffold(
body: RefreshIndicator(
onRefresh: () async {
HapticFeedback.lightImpact();
await _listStore.refreshStreams();
if (_listStore.error != null) {
final snackBar = SnackBar(
content: AlertMessage(message: _listStore.error!),
behavior: SnackBarBehavior.floating,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
},
child: Observer(
builder: (context) {
return Stack(
alignment: AlignmentDirectional.bottomCenter,
children: [
CustomScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
controller: _listStore.scrollController,
slivers: [
SliverAppBar(
leading: IconButton(
icon: Icon(Icons.adaptive.arrow_back_rounded),
onPressed: Navigator.of(context).pop,
),
stretch: true,
pinned: true,
expandedHeight: MediaQuery.of(context).size.height / 3,
flexibleSpace: FlexibleSpaceBar(
stretchModes: const [
StretchMode.fadeTitle,
StretchMode.zoomBackground,
],
centerTitle: true,
title: Text(
widget.categoryName,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
background: FutureBuilder(
future: context.read<TwitchApi>().getCategory(
headers:
context.read<AuthStore>().headersTwitch,
gameId: _listStore.categoryId!,
),
builder: (context,
AsyncSnapshot<CategoriesTwitch> snapshot) {
return snapshot.hasData
? FrostyCachedNetworkImage(
imageUrl: snapshot
.data!.data.first.boxArtUrl
.replaceRange(
snapshot.data!.data.first.boxArtUrl
.lastIndexOf('-') +
1,
null,
'${artWidth}x$artHeight.jpg',
),
placeholder: (context, url) =>
const ColoredBox(
color: lightGray,
child: LoadingIndicator()),
color: const Color.fromRGBO(
255, 255, 255, 0.5),
colorBlendMode: BlendMode.modulate,
fit: BoxFit.cover,
)
: const SizedBox();
},
),
),
),
if (_listStore.error != null)
SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(message: _listStore.error!),
)
else if (_listStore.streams.isEmpty)
if (_listStore.isLoading && _listStore.error == null)
const SliverFillRemaining(
hasScrollBody: false,
child: LoadingIndicator(
subtitle: 'Loading streams...',
),
)
else
const SliverFillRemaining(
hasScrollBody: false,
child: AlertMessage(
message: 'No streams found',
),
)
else
SliverSafeArea(
top: false,
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index > _listStore.streams.length - 10 &&
_listStore.hasMore) {
_listStore.getStreams();
}
return Observer(
builder: (context) => context
.read<SettingsStore>()
.largeStreamCard
? LargeStreamCard(
streamInfo: _listStore.streams[index],
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory: false,
)
: StreamCard(
streamInfo: _listStore.streams[index],
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory: false,
),
);
},
childCount: _listStore.streams.length,
),
),
),
],
),
SafeArea(
child: Observer(
builder: (context) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _listStore.showJumpButton
? ScrollToTopButton(
scrollController: _listStore.scrollController!)
: null,
),
),
),
],
);
},
appBar: const FrostyAppBar(
title: SizedBox(),
),
body: StreamsList(
listType: ListType.category,
categoryId: categoryId,
showJumpButton: true,
),
);
}
@override
void dispose() {
_listStore.dispose();
super.dispose();
}
}

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/home/home_store.dart';
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/home/stream_list/streams_list.dart';
@ -24,8 +23,9 @@ class _TopSectionState extends State<TopSection>
@override
void initState() {
super.initState();
_tabBarController.addListener(() =>
widget.homeStore.topSectionCurrentIndex = _tabBarController.index);
_tabBarController.addListener(
() => widget.homeStore.topSectionCurrentIndex = _tabBarController.index,
);
}
@override
@ -34,15 +34,12 @@ class _TopSectionState extends State<TopSection>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
isScrollable: true,
controller: _tabBarController,
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.0,
),
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: purple,
tabs: const [
Tab(child: Text('Streams')),
Tab(
child: Text('Streams'),
),
Tab(child: Text('Categories')),
],
),

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/widgets/button.dart';
import 'package:shared_preferences/shared_preferences.dart';
class OnboardingScaffold extends StatelessWidget {
@ -61,7 +60,7 @@ class OnboardingScaffold extends StatelessWidget {
'assets/icons/logo.svg',
height: 80,
),
const SizedBox(width: 10),
const SizedBox(width: 12),
],
Text(
header,
@ -73,7 +72,7 @@ class OnboardingScaffold extends StatelessWidget {
],
),
if (subtitle != null) ...[
const SizedBox(height: 20),
const SizedBox(height: 24),
Opacity(
opacity: 0.8,
child: Text(
@ -89,14 +88,15 @@ class OnboardingScaffold extends StatelessWidget {
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
vertical: content == null ? 20.0 : 0.0),
vertical: content == null ? 20 : 0,
),
child: content ?? const SizedBox(),
),
),
if (disclaimer != null)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 5.0),
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Opacity(
opacity: 0.5,
child: Text(
@ -107,9 +107,9 @@ class OnboardingScaffold extends StatelessWidget {
),
),
Container(
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.symmetric(horizontal: 12),
width: double.infinity,
child: Button(
child: FilledButton.icon(
onPressed: () => isLast
? Navigator.pushAndRemoveUntil(
context,
@ -122,16 +122,15 @@ class OnboardingScaffold extends StatelessWidget {
builder: (context) => route,
),
),
icon: buttonIcon,
child: Text(buttonText ?? 'Next'),
icon: buttonIcon ?? const SizedBox(),
label: Text(buttonText ?? 'Next'),
),
),
if (skipRoute != null)
Container(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 10),
margin: const EdgeInsets.symmetric(horizontal: 12),
width: double.infinity,
child: Button(
color: Colors.grey,
child: TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
@ -140,7 +139,7 @@ class OnboardingScaffold extends StatelessWidget {
),
child: const Text('Skip'),
),
)
),
],
),
),

View file

@ -20,7 +20,7 @@ class OnboardingSetup extends StatelessWidget {
return OnboardingScaffold(
header: 'Setup',
subtitle:
'Let\'s tweak some settings before you get started. You can always change these later.',
'Let\'s tweak some settings before you get started. You can always change these and more later.',
content: Observer(
builder: (context) {
return ListView(
@ -35,7 +35,8 @@ class OnboardingSetup extends StatelessWidget {
SettingsListSwitch(
title: 'Share crash logs and analytics',
subtitle: const Text(
'Help improve Frosty by sending anonymous crash logs and analytics through Firebase.'),
'Help improve Frosty by sending anonymous crash logs and analytics through Firebase.',
),
value: settingsStore.shareCrashLogsAndAnalytics,
onChanged: (newValue) {
settingsStore.shareCrashLogsAndAnalytics = newValue;

View file

@ -11,7 +11,7 @@ class OnboardingWelcome extends StatelessWidget {
const text = [
'Check out the GitHub repo at the top-right of the settings page to explore Frosty\'s source code, report bugs, make feature requests, and more.',
'Links to the full changelog and FAQ are in the settings page under "Other".',
'Don\'t forget to leave a rating and/or review on the app store!'
'Don\'t forget to leave a rating and/or review on the app store!',
];
return OnboardingScaffold(
@ -20,10 +20,11 @@ class OnboardingWelcome extends StatelessWidget {
content: Opacity(
opacity: 0.8,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
padding: const EdgeInsets.symmetric(horizontal: 12),
children: text
.mapIndexed((index, sentence) => Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
.mapIndexed(
(index, sentence) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: Text(
'${index + 1}.',
@ -34,7 +35,8 @@ class OnboardingWelcome extends StatelessWidget {
),
title: Text(sentence),
),
))
),
)
.toList(),
),
),

View file

@ -2,9 +2,6 @@ import 'package:flutter/material.dart';
import 'package:frosty/screens/settings/account/blocked_users.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
class AccountOptions extends StatelessWidget {
final AuthStore authStore;
@ -14,11 +11,15 @@ class AccountOptions extends StatelessWidget {
Future<void> _showLogoutDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Log Out',
message: 'Are you sure you want to log out?',
builder: (context) => AlertDialog.adaptive(
title: const Text('Log out'),
content: const Text('Are you sure you want to log out?'),
actions: [
Button(
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
authStore.logout();
Navigator.pop(context);
@ -26,11 +27,6 @@ class AccountOptions extends StatelessWidget {
},
child: const Text('Yes'),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Cancel'),
)
],
),
);
@ -39,20 +35,21 @@ class AccountOptions extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
shrinkWrap: true,
primary: false,
children: [
SettingsTileRoute(
leading: const Icon(Icons.block_rounded),
title: 'Blocked',
title: 'Blocked users',
child: BlockedUsers(
authStore: authStore,
),
),
FrostyListTile(
ListTile(
leading: const Icon(Icons.logout_rounded),
title: 'Log out',
trailing: const Icon(Icons.chevron_right_rounded),
title: const Text('Log out'),
onTap: () => _showLogoutDialog(context),
)
),
],
);
}

View file

@ -4,8 +4,6 @@ import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/list_tile.dart';
class BlockedUsers extends StatelessWidget {
final AuthStore authStore;
@ -17,7 +15,7 @@ class BlockedUsers extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
return RefreshIndicator.adaptive(
onRefresh: () async {
HapticFeedback.lightImpact();
@ -41,10 +39,12 @@ class BlockedUsers extends StatelessWidget {
? blockedUser.displayName
: '${blockedUser.displayName} (${blockedUser.userLogin})';
return FrostyListTile(
title: displayName,
trailing: Button(
color: Colors.red,
return ListTile(
title: Text(displayName),
trailing: TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
onPressed: () => authStore.showBlockDialog(
context,
targetUser: displayName,

View file

@ -1,14 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/account/account_options.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:simple_icons/simple_icons.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ProfileCard extends StatelessWidget {
@ -16,17 +12,54 @@ class ProfileCard extends StatelessWidget {
const ProfileCard({Key? key, required this.authStore}) : super(key: key);
Future<void> _showLoginDialog(BuildContext context) {
return showDialog(
Future<void> _showAccountOptionsModalBottomSheet(BuildContext context) {
return showModalBottomSheet(
context: context,
builder: (context) => FrostyDialog(
title: 'Log In',
content: Column(
children: [
Button(
padding:
const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0),
onPressed: () => Navigator.push(
builder: (context) {
return AccountOptions(authStore: authStore);
},
);
}
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
if (authStore.error != null) {
return ListTile(
leading: const Icon(
Icons.error_outline_rounded,
color: Colors.red,
),
title: const Text('Failed to connect'),
trailing: FilledButton.tonal(
onPressed: authStore.init,
child: const Text('Reconnect'),
),
);
}
if (authStore.isLoggedIn && authStore.user.details != null) {
return ListTile(
leading: ProfilePicture(
userLogin: authStore.user.details!.login,
radius: 12,
),
title: Text(authStore.user.details!.displayName),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _showAccountOptionsModalBottomSheet(context),
);
}
return ListTile(
leading: const Icon(Icons.no_accounts_rounded),
title: const Text('Anonymous'),
subtitle: const Text(
'Log in to enable the ability to chat, view followed streams, and more.',
),
trailing: const SizedBox(
height: double.infinity,
child: Icon(Icons.chevron_right_rounded),
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
@ -41,79 +74,14 @@ class ProfileCard extends StatelessWidget {
},
),
),
icon: const Icon(SimpleIcons.twitch),
child: const Text(
'Connect with Twitch',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 20.0),
const Center(
child: Text(
'Or',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(height: 20.0),
TextField(
autocorrect: false,
textAlign: TextAlign.center,
decoration: const InputDecoration(
hintText: 'Token',
),
onSubmitted: (token) {
authStore.login(token: token);
Navigator.pop(context);
},
)
],
),
),
);
}
onLongPress: () async {
final clipboardText =
(await Clipboard.getData(Clipboard.kTextPlain))?.text;
@override
Widget build(BuildContext context) {
return Observer(
builder: (context) {
if (authStore.error != null) {
return FrostyListTile(
leading: const Icon(
Icons.error_outline_rounded,
color: Colors.red,
),
title: 'Failed to connect',
trailing: Button(
onPressed: authStore.init,
padding: const EdgeInsets.symmetric(horizontal: 15.0),
child: const Text('Reconnect'),
),
);
}
if (authStore.isLoggedIn && authStore.user.details != null) {
return SettingsTileRoute(
leading: ProfilePicture(
userLogin: authStore.user.details!.login,
radius: 12,
),
title: authStore.user.details!.displayName,
child: AccountOptions(authStore: authStore),
);
}
return FrostyListTile(
leading: const Icon(Icons.no_accounts_rounded),
title: 'Anonymous',
subtitle: const Text(
'Tap to log in and enable the ability to chat, view followed streams, and more.'),
trailing: const SizedBox(
height: double.infinity,
child: Icon(Icons.chevron_right_rounded),
),
onTap: () => _showLoginDialog(context),
if (clipboardText == null) return;
authStore.login(token: clipboardText);
},
);
},
);

View file

@ -31,8 +31,10 @@ class _ChatSettingsState extends State<ChatSettings> {
children: [
const SectionHeader('Message sizing'),
ExpansionTile(
title: const Text('Preview',
style: TextStyle(fontWeight: FontWeight.w500)),
title: const Text(
'Preview',
style: TextStyle(fontWeight: FontWeight.w500),
),
children: [
Container(
width: double.infinity,
@ -122,7 +124,7 @@ class _ChatSettingsState extends State<ChatSettings> {
trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px',
value: settingsStore.messageSpacing,
max: 30.0,
divisions: 6,
divisions: 15,
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
),
SettingsListSlider(
@ -134,18 +136,20 @@ class _ChatSettingsState extends State<ChatSettings> {
divisions: 15,
onChanged: (newValue) => settingsStore.fontSize = newValue,
),
const SectionHeader('Message appearance'),
const SectionHeader('Message appearance', showDivider: true),
SettingsListSwitch(
title: 'Use readable name colors',
subtitle: const Text(
'Adjusts the lightness value of overly bright and dark names.'),
'Adjusts the lightness value of overly bright and dark names.',
),
value: settingsStore.useReadableColors,
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
),
SettingsListSwitch(
title: 'Show deleted messages',
subtitle: const Text(
'Restores the original message of deleted messages.'),
'Restores the original message of deleted messages.',
),
value: settingsStore.showDeletedMessages,
onChanged: (newValue) =>
settingsStore.showDeletedMessages = newValue,
@ -163,7 +167,7 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) => settingsStore.timestampType =
TimestampType.values[timestampNames.indexOf(newValue)],
),
const SectionHeader('Delay'),
const SectionHeader('Delay', showDivider: true),
SettingsListSlider(
title: 'Message delay',
trailing: '${settingsStore.chatDelay.toInt()}s',
@ -174,7 +178,7 @@ class _ChatSettingsState extends State<ChatSettings> {
divisions: 30,
onChanged: (newValue) => settingsStore.chatDelay = newValue,
),
const SectionHeader('Alerts'),
const SectionHeader('Alerts', showDivider: true),
SettingsListSwitch(
title: 'Highlight first time chatters',
value: settingsStore.highlightFirstTimeChatter,
@ -184,11 +188,12 @@ class _ChatSettingsState extends State<ChatSettings> {
SettingsListSwitch(
title: 'Show notices',
subtitle: const Text(
'Shows notices such as subs and re-subs, announcements, and raids.'),
'Shows notices such as subs and re-subs, announcements, and raids.',
),
value: settingsStore.showUserNotices,
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
),
const SectionHeader('Layout'),
const SectionHeader('Layout', showDivider: true),
SettingsListSwitch(
title: 'Show bottom bar',
value: settingsStore.showBottomBar,
@ -197,7 +202,8 @@ class _ChatSettingsState extends State<ChatSettings> {
SettingsListSwitch(
title: 'Move emote menu button left',
subtitle: const Text(
'Places the emote menu button on the left side to avoid accidental presses.'),
'Places the emote menu button on the left side to avoid accidental presses.',
),
value: settingsStore.emoteMenuButtonOnLeft,
onChanged: (newValue) =>
settingsStore.emoteMenuButtonOnLeft = newValue,
@ -208,7 +214,7 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) =>
settingsStore.chatNotificationsOnBottom = newValue,
),
const SectionHeader('Landscape mode'),
const SectionHeader('Landscape mode', showDivider: true),
SettingsListSwitch(
title: 'Move chat left',
value: settingsStore.landscapeChatLeftSide,
@ -254,20 +260,22 @@ class _ChatSettingsState extends State<ChatSettings> {
onChanged: (newValue) =>
settingsStore.fullScreenChatOverlayOpacity = newValue,
),
const SectionHeader('Sleep'),
const SectionHeader('Sleep', showDivider: true),
SettingsListSwitch(
title: 'Prevent sleep in chat-only mode',
subtitle: const Text(
'Requires restarting the chat in order to take effect.'),
'Requires restarting the chat in order to take effect.',
),
value: settingsStore.chatOnlyPreventSleep,
onChanged: (newValue) =>
settingsStore.chatOnlyPreventSleep = newValue,
),
const SectionHeader('Autocomplete'),
const SectionHeader('Autocomplete', showDivider: true),
SettingsListSwitch(
title: 'Show autocomplete bar',
subtitle: const Text(
'Shows a bar containing matching emotes and mentions while typing.'),
'Shows a bar containing matching emotes and mentions while typing.',
),
value: settingsStore.autocomplete,
onChanged: (newValue) => settingsStore.autocomplete = newValue,
),

View file

@ -24,7 +24,7 @@ class GeneralSettings extends StatelessWidget {
onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)],
),
const SectionHeader('Stream card'),
const SectionHeader('Stream card', showDivider: true),
SettingsListSwitch(
title: 'Use large stream card',
value: settingsStore.largeStreamCard,
@ -35,7 +35,7 @@ class GeneralSettings extends StatelessWidget {
value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
),
const SectionHeader('Links'),
const SectionHeader('Links', showDivider: true),
SettingsListSwitch(
title: 'Open links in external browser',
value: settingsStore.launchUrlExternal,

View file

@ -9,9 +9,6 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
@ -31,11 +28,15 @@ class _OtherSettingsState extends State<OtherSettings> {
Future<void> _showConfirmDialog(BuildContext context) {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: 'Reset All Settings',
message: 'Are you sure you want to reset all settings?',
builder: (context) => AlertDialog.adaptive(
title: const Text('Reset all settings'),
content: const Text('Are you sure you want to reset all settings?'),
actions: [
Button(
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
@ -45,18 +46,15 @@ class _OtherSettingsState extends State<OtherSettings> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(message: 'All settings reset'),
behavior: SnackBarBehavior.floating,
content: AlertMessage(
message: 'All settings reset',
centered: false,
),
),
);
},
child: const Text('Yes'),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Cancel'),
),
],
),
);
@ -66,9 +64,9 @@ class _OtherSettingsState extends State<OtherSettings> {
Widget build(BuildContext context) {
return Column(
children: [
FrostyListTile(
ListTile(
leading: const Icon(Icons.info_outline_rounded),
title: 'About Frosty',
title: const Text('About Frosty'),
onTap: () async {
final packageInfo = await PackageInfo.fromPlatform();
@ -87,26 +85,29 @@ class _OtherSettingsState extends State<OtherSettings> {
);
},
),
FrostyListTile(
ListTile(
leading: const Icon(Icons.launch_rounded),
title: 'Changelog',
title: const Text('Changelog'),
onTap: () => launchUrl(
Uri.parse('https://github.com/tommyxchow/frosty/releases'),
mode: widget.settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
: LaunchMode.inAppWebView,
),
FrostyListTile(
),
ListTile(
leading: const Icon(Icons.launch_rounded),
title: 'FAQ',
onTap: () => launchUrl(Uri.parse('https://www.frostyapp.io/#faq'),
title: const Text('FAQ'),
onTap: () => launchUrl(
Uri.parse('https://www.frostyapp.io/#faq'),
mode: widget.settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
: LaunchMode.inAppWebView,
),
FrostyListTile(
),
ListTile(
leading: const Icon(Icons.delete_outline_rounded),
title: 'Clear image cache',
title: const Text('Clear image cache'),
onTap: () async {
HapticFeedback.mediumImpact();
@ -115,22 +116,25 @@ class _OtherSettingsState extends State<OtherSettings> {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: AlertMessage(message: 'Image cache cleared'),
behavior: SnackBarBehavior.floating,
content: AlertMessage(
message: 'Image cache cleared',
centered: false,
),
),
);
},
),
FrostyListTile(
ListTile(
leading: const Icon(Icons.restore_rounded),
title: 'Reset settings',
title: const Text('Reset settings'),
onTap: () => _showConfirmDialog(context),
),
Observer(
builder: (_) => SettingsListSwitch(
title: 'Share crash logs and analytics',
subtitle: const Text(
'Help improve Frosty by sending anonymous crash logs and analytics through Firebase.'),
'Help improve Frosty by sending anonymous crash logs and analytics through Firebase.',
),
value: widget.settingsStore.shareCrashLogsAndAnalytics,
onChanged: (newValue) {
widget.settingsStore.shareCrashLogsAndAnalytics = newValue;

View file

@ -33,7 +33,8 @@ class Settings extends StatelessWidget {
Uri.parse('https://www.buymeacoffee.com/tommychow'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
: LaunchMode.inAppWebView,
),
icon: const Icon(SimpleIcons.buymeacoffee),
),
IconButton(
@ -42,7 +43,8 @@ class Settings extends StatelessWidget {
Uri.parse('https://github.com/tommyxchow/frosty'),
mode: settingsStore.launchUrlExternal
? LaunchMode.externalApplication
: LaunchMode.inAppWebView),
: LaunchMode.inAppWebView,
),
icon: const Icon(SimpleIcons.github),
),
],
@ -51,9 +53,9 @@ class Settings extends StatelessWidget {
bottom: false,
child: ListView(
children: [
const SectionHeader('Account'),
const SectionHeader('Profile'),
ProfileCard(authStore: context.read<AuthStore>()),
const SectionHeader('Customize'),
const SectionHeader('Options', showDivider: true),
SettingsTileRoute(
leading: const Icon(Icons.settings_outlined),
title: 'General',
@ -69,8 +71,8 @@ class Settings extends StatelessWidget {
title: 'Chat',
child: ChatSettings(settingsStore: settingsStore),
),
const SectionHeader('Other'),
OtherSettings(settingsStore: settingsStore)
const SectionHeader('Other', showDivider: true),
OtherSettings(settingsStore: settingsStore),
],
),
),

View file

@ -6,8 +6,6 @@ import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.dart';
import 'package:frosty/main.dart';
import 'package:frosty/screens/settings/stores/user_store.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:mobx/mobx.dart';
import 'package:webview_flutter/webview_flutter.dart';
@ -52,8 +50,10 @@ abstract class AuthBase with Store {
String? _error;
/// Navigation handler for the login webview. Fires on every navigation request (whenever the URL changes).
FutureOr<NavigationDecision> handleNavigation(
{required NavigationRequest request, Widget? routeAfter}) {
FutureOr<NavigationDecision> handleNavigation({
required NavigationRequest request,
Widget? routeAfter,
}) {
// Check if the URL is the redirect URI.
if (request.url.startsWith('https://twitch.tv/login')) {
// Extract the token from the query parameters.
@ -119,10 +119,10 @@ abstract class AuthBase with Store {
.where((blockedUser) => blockedUser.userId == targetUserId)
.isNotEmpty;
final title = isBlocked ? 'Unblock $targetUser' : 'Block $targetUser';
final title = isBlocked ? 'Unblock' : 'Block';
final message =
'Are you sure you want to ${isBlocked ? 'unblock $targetUser?' : 'block $targetUser? This will remove them from channel lists, search results, and chat messages.'}';
'Are you sure you want to ${isBlocked ? 'unblock "$targetUser"?' : 'block "$targetUser"? This will remove them from channel lists, search results, and chat messages.'}';
void onPressed() {
if (isBlocked) {
@ -139,19 +139,18 @@ abstract class AuthBase with Store {
return showDialog(
context: context,
builder: (context) => FrostyDialog(
title: title,
message: message,
builder: (context) => AlertDialog.adaptive(
title: Text(title),
content: Text(message),
actions: [
Button(
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: onPressed,
child: const Text('Yes'),
),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Cancel'),
),
],
),
);

View file

@ -164,7 +164,8 @@ abstract class _SettingsStoreBase with Store {
@JsonKey(
defaultValue: defaultTimestampType,
unknownEnumValue: TimestampType.disabled)
unknownEnumValue: TimestampType.disabled,
)
@observable
var timestampType = defaultTimestampType;

View file

@ -34,8 +34,9 @@ abstract class UserStoreBase with Store {
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
}
_disposeReaction = autorun((_) =>
_blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)));
_disposeReaction = autorun(
(_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)),
);
}
@action
@ -53,18 +54,23 @@ abstract class UserStoreBase with Store {
}
@action
Future<void> unblock(
{required String targetId, required Map<String, String> headers}) async {
Future<void> unblock({
required String targetId,
required Map<String, String> headers,
}) async {
final success =
await twitchApi.unblockUser(userId: targetId, headers: headers);
if (success) await refreshBlockedUsers(headers: headers);
}
@action
Future<void> refreshBlockedUsers(
{required Map<String, String> headers}) async =>
Future<void> refreshBlockedUsers({
required Map<String, String> headers,
}) async =>
_blockedUsers = (await twitchApi.getUserBlockedList(
id: _details!.id, headers: headers))
id: _details!.id,
headers: headers,
))
.asObservable();
@action

View file

@ -22,18 +22,20 @@ class VideoSettings extends StatelessWidget {
value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue,
),
const SectionHeader('Overlay'),
const SectionHeader('Overlay', showDivider: true),
SettingsListSwitch(
title: 'Use custom video overlay',
subtitle: const Text(
'Replaces Twitch\'s default web overlay with a mobile-friendly version.'),
'Replaces Twitch\'s default web overlay with a mobile-friendly version.',
),
value: settingsStore.showOverlay,
onChanged: (newValue) => settingsStore.showOverlay = newValue,
),
SettingsListSwitch(
title: 'Long-press player to toggle overlay',
subtitle: const Text(
'Allows switching between Twitch\'s overlay and the custom overlay.'),
'Allows switching between Twitch\'s overlay and the custom overlay.',
),
value: settingsStore.toggleableOverlay,
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,
),

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
/// A custom-styled adaptive [ListTile] with options to select.
class SettingsListSelect extends StatelessWidget {
@ -21,53 +20,24 @@ class SettingsListSelect extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
isThreeLine: subtitle != null,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: SizedBox(
height: double.infinity,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: 0.8,
child: Text(
selectedOption,
title: Text(title),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SegmentedButton(
style: const ButtonStyle(visualDensity: VisualDensity.compact),
segments: options
.map(
(option) => ButtonSegment(
value: option,
label: Text(
option,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.chevron_right_rounded),
],
),
),
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) => FrostyBottomSheet(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Select ${title.toLowerCase()}',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18),
),
),
...options
.map((option) => ListTile(
title: Text(option),
trailing: selectedOption == option
? const Icon(Icons.check_rounded)
: null,
onTap: () {
onChanged(option);
Navigator.of(context).pop();
}))
.toList()
],
),
)
.toList(),
selected: {selectedOption},
onSelectionChanged: (selection) => onChanged(selection.first),
),
),
);

View file

@ -36,9 +36,12 @@ class SettingsListSlider extends StatelessWidget {
children: [
Text(title, style: _textStyle),
const Spacer(),
Text(trailing,
Text(
trailing,
style: _textStyle.copyWith(
fontFeatures: [const FontFeature.tabularFigures()])),
fontFeatures: [const FontFeature.tabularFigures()],
),
),
],
),
subtitle: Column(

View file

@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
/// A custom-styled adaptive [SwitchListTile].
class SettingsListSwitch extends StatelessWidget {
final String? title;
final String title;
final Widget? subtitle;
final bool value;
final ValueChanged<bool>? onChanged;
const SettingsListSwitch({
Key? key,
this.title,
required this.title,
this.subtitle,
this.onChanged,
required this.value,
@ -19,7 +19,7 @@ class SettingsListSwitch extends StatelessWidget {
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
isThreeLine: subtitle != null,
title: Text(title!, style: const TextStyle(fontWeight: FontWeight.w500)),
title: Text(title),
subtitle: subtitle,
value: value,
onChanged: onChanged,

View file

@ -17,7 +17,7 @@ class SettingsTileRoute extends StatelessWidget {
Widget build(BuildContext context) {
return ListTile(
leading: leading,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
title: Text(title),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(

156
lib/theme.dart Normal file
View file

@ -0,0 +1,156 @@
import 'dart:io';
import 'package:flutter/material.dart';
class FrostyThemes {
static const gray = Color.fromRGBO(18, 18, 18, 1);
static const purple = Color(0xff9146ff);
ThemeData createBaseTheme({
required Brightness brightness,
required Color colorSchemeSeed,
Color? backgroundColor,
}) {
final secondaryBackground = brightness == Brightness.light
? Colors.grey.shade200
: Colors.grey.shade800;
return ThemeData(
useMaterial3: true,
fontFamily: 'Inter',
brightness: brightness,
colorSchemeSeed: colorSchemeSeed,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
scaffoldBackgroundColor: backgroundColor,
bottomSheetTheme: BottomSheetThemeData(
showDragHandle: true,
backgroundColor: backgroundColor,
surfaceTintColor: backgroundColor,
),
dialogTheme: DialogTheme(
backgroundColor: backgroundColor,
surfaceTintColor: backgroundColor,
),
appBarTheme: AppBarTheme(
elevation: 0,
backgroundColor: backgroundColor,
surfaceTintColor: backgroundColor,
),
inputDecorationTheme: InputDecorationTheme(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide(color: secondaryBackground),
),
enabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide(color: secondaryBackground),
),
disabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide(color: secondaryBackground),
),
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
backgroundColor: backgroundColor,
),
tabBarTheme: const TabBarTheme(
dividerColor: Colors.transparent,
),
tooltipTheme: TooltipThemeData(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: secondaryBackground),
),
textStyle: TextStyle(
color: brightness == Brightness.dark ? Colors.white : Colors.black,
),
),
snackBarTheme: SnackBarThemeData(
showCloseIcon: true,
backgroundColor: backgroundColor,
shape: RoundedRectangleBorder(
side: BorderSide(color: secondaryBackground),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
behavior: SnackBarBehavior.floating,
),
dividerTheme: const DividerThemeData(
thickness: 0.5,
space: 0.5,
),
textTheme: const TextTheme(
// Used in alert dialog title.
headlineSmall: TextStyle(
fontWeight: FontWeight.bold,
),
// Used in app bar title.
titleLarge: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
titleMedium: TextStyle(letterSpacing: 0),
// Used in tab bar title.
titleSmall: TextStyle(
fontSize: 16,
letterSpacing: 0,
fontWeight: FontWeight.w600,
),
labelLarge: TextStyle(
letterSpacing: 0,
),
labelMedium: TextStyle(
letterSpacing: 0,
),
labelSmall: TextStyle(
letterSpacing: 0,
),
// Used in list tile title.
bodyLarge: TextStyle(
letterSpacing: 0,
fontWeight: FontWeight.w500,
),
bodyMedium: TextStyle(
letterSpacing: 0,
),
bodySmall: TextStyle(
letterSpacing: 0,
),
),
);
}
ThemeData get light {
final theme = createBaseTheme(
brightness: Brightness.light,
colorSchemeSeed: purple,
backgroundColor: Colors.white,
);
return theme;
}
ThemeData get dark {
final theme = createBaseTheme(
brightness: Brightness.dark,
colorSchemeSeed: purple,
backgroundColor: gray,
);
return theme;
}
ThemeData get black {
final theme = createBaseTheme(
brightness: Brightness.dark,
colorSchemeSeed: purple,
backgroundColor: Colors.black,
);
return theme;
}
}

View file

@ -4,23 +4,26 @@ import 'package:flutter/material.dart';
class AlertMessage extends StatelessWidget {
final String message;
final Color? color;
final bool centered;
const AlertMessage({
Key? key,
required this.message,
this.centered = true,
this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment:
centered ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
Icon(
Icons.info_outline_rounded,
color: color ?? Colors.grey,
),
const SizedBox(width: 5.0),
const SizedBox(width: 8),
Flexible(
child: Text(
message,

View file

@ -1,67 +0,0 @@
import 'package:flutter/material.dart';
/// A widget than animates its scale when tapped/held on.
class AnimateScale extends StatefulWidget {
final void Function()? onTap;
final void Function()? onLongPress;
final Widget child;
const AnimateScale({
Key? key,
this.onTap,
this.onLongPress,
required this.child,
}) : super(key: key);
@override
State<AnimateScale> createState() => _AnimateScaleState();
}
class _AnimateScaleState extends State<AnimateScale>
with SingleTickerProviderStateMixin {
late final _animationController = AnimationController(
vsync: this,
upperBound: 0.05,
);
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 200);
const downCurve = Curves.easeOut;
const upCurve = Curves.easeIn;
return InkWell(
onTap: widget.onTap,
onTapDown: (_) => _animationController.animateTo(
_animationController.upperBound,
curve: downCurve,
duration: duration,
),
onLongPress: widget.onLongPress,
onTapUp: (_) => _animationController.animateTo(
_animationController.lowerBound,
curve: upCurve,
duration: duration,
),
onTapCancel: () => _animationController.animateTo(
_animationController.lowerBound,
curve: upCurve,
duration: duration,
),
child: AnimatedBuilder(
animation: _animationController,
child: widget.child,
builder: (context, child) => Transform.scale(
scale: 1 - _animationController.value,
child: child,
),
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}

View file

@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/app_bar.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:webview_flutter/webview_flutter.dart';
class BlockReportModal extends StatelessWidget {
@ -21,20 +19,23 @@ class BlockReportModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FrostyBottomSheet(
child: Column(
mainAxisSize: MainAxisSize.min,
return ListView(
primary: false,
shrinkWrap: true,
children: [
if (authStore.isLoggedIn)
FrostyListTile(
ListTile(
leading: const Icon(Icons.block_rounded),
onTap: () => authStore
.showBlockDialog(context,
targetUser: name, targetUserId: userId)
.showBlockDialog(
context,
targetUser: name,
targetUserId: userId,
)
.then((_) => Navigator.pop(context)),
title: 'Block $name',
title: Text('Block $name'),
),
FrostyListTile(
ListTile(
leading: const Icon(Icons.outlined_flag_rounded),
onTap: () => Navigator.push(
context,
@ -55,10 +56,9 @@ class BlockReportModal extends StatelessWidget {
},
),
),
title: 'Report $name',
)
],
title: Text('Report $name'),
),
],
);
}
}

View file

@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
class FrostyBottomSheet extends StatelessWidget {
final Widget child;
const FrostyBottomSheet({Key? key, required this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(15.0),
topRight: Radius.circular(15.0),
),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const FractionallySizedBox(
widthFactor: 0.2,
child: SizedBox(
child: Divider(
height: 30.0,
thickness: 2.0,
),
),
),
Material(child: child),
],
),
),
);
}
}

View file

@ -1,126 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
/// A custom button that scales down when tapped/held on (sorta like a real button).
class Button extends StatefulWidget {
final Color? color;
final bool fill;
final EdgeInsets padding;
final double? fontSize;
final Widget? icon;
final Function()? onPressed;
final Widget child;
const Button({
Key? key,
this.color,
this.fill = false,
this.padding = const EdgeInsets.symmetric(vertical: 15.0, horizontal: 10.0),
this.fontSize,
this.icon,
required this.onPressed,
required this.child,
}) : super(key: key);
@override
State<Button> createState() => _ButtonState();
}
class _ButtonState extends State<Button> with SingleTickerProviderStateMixin {
late final _animationController = AnimationController(
vsync: this,
upperBound: 0.05,
);
@override
Widget build(BuildContext context) {
final buttonStyle = ElevatedButton.styleFrom(
foregroundColor: widget.fill ? null : widget.color,
backgroundColor: widget.color == null || widget.fill
? widget.color
: Colors.transparent,
padding: widget.padding,
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
textStyle: DefaultTextStyle.of(context)
.style
.copyWith(fontWeight: FontWeight.w600, fontSize: widget.fontSize),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)),
elevation: widget.color == null ? 10.0 : 0.0,
).copyWith(
elevation:
(widget.color == null || widget.fill) && widget.onPressed != null
? MaterialStateProperty.resolveWith(
(states) {
if (states.contains(MaterialState.pressed)) {
return 0.0;
} else {
return 5.0;
}
},
)
: MaterialStateProperty.all(0.0),
);
final button = widget.icon == null
? ElevatedButton(
style: buttonStyle,
onPressed: widget.onPressed,
child: widget.child,
)
: ElevatedButton.icon(
style: buttonStyle,
onPressed: widget.onPressed,
icon: widget.icon!,
label: widget.child,
);
const duration = Duration(milliseconds: 200);
return AnimatedBuilder(
animation: _animationController,
child: widget.onPressed == null
? button
: Listener(
onPointerDown: (_) {
if (mounted) {
_animationController.animateTo(
_animationController.upperBound,
curve: Curves.easeOut,
duration: duration,
);
}
},
onPointerUp: (_) {
if (mounted) {
_animationController.animateTo(
_animationController.lowerBound,
curve: Curves.easeIn,
duration: duration,
);
}
},
onPointerCancel: (_) {
if (mounted) {
_animationController.animateTo(
_animationController.lowerBound,
curve: Curves.easeIn,
duration: duration,
);
}
},
child: button,
),
builder: (context, child) => Transform.scale(
scale: 1 - _animationController.value,
child: child,
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
}

View file

@ -17,31 +17,28 @@ class FrostyDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(20.0, 30.0, 20.0, 20.0),
contentPadding: const EdgeInsets.fromLTRB(20.0, 0.0, 20.0, 20.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
return AlertDialog.adaptive(
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
// style: const TextStyle(fontWeight: FontWeight.bold),
// textAlign: TextAlign.center,
),
content: SingleChildScrollView(
child: Column(children: [
child: Column(
children: [
content ?? Text(message!, textAlign: TextAlign.center),
if (actions != null) ...[
const SizedBox(height: 25.0),
const SizedBox(height: 16),
...?actions?.map(
(action) => Container(
width: double.infinity,
padding: const EdgeInsets.only(top: 5.0),
padding: const EdgeInsets.only(top: 4),
child: action,
),
),
],
]),
],
),
),
);
}

View file

@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
class FrostyListTile extends StatelessWidget {
final bool? isThreeLine;
final Widget? leading;
final String title;
final Widget? subtitle;
final Widget? trailing;
final GestureTapCallback? onTap;
const FrostyListTile({
Key? key,
this.isThreeLine,
this.leading,
required this.title,
this.trailing,
this.onTap,
this.subtitle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
isThreeLine: isThreeLine ?? subtitle != null,
leading: leading,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: subtitle,
trailing: trailing,
onTap: onTap,
);
}
}

View file

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

View file

@ -1,6 +1,4 @@
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
import 'package:frosty/widgets/button.dart';
class FrostyNotification extends StatelessWidget {
final String message;
@ -18,8 +16,8 @@ class FrostyNotification extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: lightGray,
borderRadius: BorderRadius.circular(10.0),
color: Colors.grey.shade900,
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.all(10),
child: Row(
@ -49,9 +47,8 @@ class FrostyNotification extends StatelessWidget {
),
),
if (showPasteButton)
Button(
TextButton(
onPressed: onButtonPressed,
color: purple,
child: const Text('Paste'),
),
],

View file

@ -1,6 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:frosty/widgets/button.dart';
class FrostyPageView extends StatefulWidget {
final List<String> headers;
@ -18,63 +16,34 @@ class FrostyPageView extends StatefulWidget {
}
class _FrostyPageViewState extends State<FrostyPageView> {
var currentIndex = 0;
late final PageController _pageContoller =
PageController(initialPage: currentIndex);
@override
Widget build(BuildContext context) {
return Column(
return DefaultTabController(
length: widget.headers.length,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(
height: 5.0,
thickness: 1.0,
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
...widget.headers.mapIndexed(
(index, section) => SizedBox(
height: 40,
child: Button(
padding: const EdgeInsets.symmetric(
horizontal: 10.0, vertical: 5.0),
onPressed: () => _pageContoller.animateToPage(
index,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
),
color: index == currentIndex
? Theme.of(context).colorScheme.secondary
: Colors.grey,
child: Text(
section,
style: const TextStyle(fontWeight: FontWeight.w600),
TabBar(
isScrollable: true,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
labelPadding: const EdgeInsets.symmetric(
horizontal: 12,
),
tabs: widget.headers
.map(
(header) => Tab(
text: header,
),
)
.toList(),
),
const Divider(),
Expanded(child: TabBarView(children: widget.children)),
],
),
),
Expanded(
child: PageView.builder(
onPageChanged: (index) => setState(() => currentIndex = index),
controller: _pageContoller,
itemBuilder: (context, index) => widget.children[index],
itemCount: widget.children.length,
),
),
],
);
}
@override
void dispose() {
_pageContoller.dispose();
super.dispose();
}
}

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/user.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/cached_image.dart';
@ -24,25 +23,21 @@ class ProfilePicture extends StatelessWidget {
child: FutureBuilder(
future: context.read<TwitchApi>().getUser(
userLogin: userLogin,
headers: context.read<AuthStore>().headersTwitch),
headers: context.read<AuthStore>().headersTwitch,
),
builder: (context, AsyncSnapshot<UserTwitch> snapshot) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: snapshot.hasData
return snapshot.hasData
? FrostyCachedNetworkImage(
width: diameter,
height: diameter,
imageUrl: snapshot.data!.profileImageUrl,
placeholder: (context, url) =>
const ColoredBox(color: lightGray),
ColoredBox(color: Colors.grey.shade900),
)
: Container(
color: lightGray,
color: Colors.grey.shade900,
width: diameter,
height: diameter,
),
);
},
),

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:frosty/widgets/button.dart';
class ScrollToTopButton extends StatelessWidget {
final ScrollController scrollController;
@ -9,19 +8,24 @@ class ScrollToTopButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 5.0),
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Tooltip(
message: 'Scroll to top',
preferBelow: false,
child: Button(
child: ElevatedButton(
onPressed: () => scrollController.animateTo(
0.0,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 500),
),
padding: const EdgeInsets.symmetric(horizontal: 25.0, vertical: 10.0),
child: const Icon(Icons.keyboard_double_arrow_up_rounded),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: const Icon(Icons.arrow_upward_rounded),
),
),
),
);

View file

@ -1,30 +1,37 @@
import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
class SectionHeader extends StatelessWidget {
final String text;
final EdgeInsets? padding;
final double? fontSize;
final bool showDivider;
const SectionHeader(
this.text, {
Key? key,
this.padding,
this.fontSize,
this.showDivider = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.fromLTRB(16, 30, 16, 5),
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDivider) const Divider(),
Padding(
padding: padding ?? const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: TextStyle(
color: purple,
fontSize: fontSize ?? 16,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ description: A Twitch client for iOS and Android.
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 3.1.4+40
environment:
sdk: '>=3.0.0 <4.0.0'
sdk: ">=3.0.0 <4.0.0"
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
@ -31,13 +31,12 @@ dependencies:
sdk: flutter
json_annotation: ^4.6.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
http: ^0.13.3
http: ^1.1.0
web_socket_channel: ^2.1.0
flutter_secure_storage: ^8.0.0
flutter_secure_storage: ^9.0.0
intl: ^0.18.1
cached_network_image: ^3.1.0
webview_flutter: ^4.2.0
@ -47,11 +46,11 @@ dependencies:
shared_preferences: ^2.0.8
flutter_svg: ^2.0.5
url_launcher: ^6.0.17
package_info_plus: ^3.0.2
package_info_plus: ^4.1.0
collection: ^1.16.0
flutter_cache_manager: ^3.3.0
device_info_plus: ^8.0.0
wakelock: ^0.6.2
device_info_plus: ^9.0.3
wakelock_plus: ^1.1.2
simple_icons: ^7.10.0
simple_pip_mode:
git:
@ -85,7 +84,6 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
@ -111,6 +109,7 @@ flutter:
assets:
- assets/icons/logo.svg
- assets/fonts/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware
@ -139,7 +138,6 @@ flutter:
# see https://flutter.dev/custom-fonts/#from-packages
flutter_native_splash:
# This package generates native code to customize Flutter's default white native splash screen
# with background color and splash image.
# Customize the parameters below, and run the following command in the terminal:
@ -223,7 +221,7 @@ flutter_native_splash:
# - 'ios/Runner/Info-Debug.plist'
# - 'ios/Runner/Info-Release.plist'
flutter_icons:
flutter_launcher_icons:
image_path_android: "assets/icons/icon_android.png"
image_path_ios: "assets/icons/icon_ios.png"
android: true

View file

@ -101,7 +101,7 @@ void main() {
'54fa903b01e468494b85b53f',
'54fa909b01e468494b85b542',
'54fa90ba01e468494b85b543',
'54fa90f201e468494b85b545'
'54fa90f201e468494b85b545',
];
final emoteCodes = ['DatSauce', 'ForeverAlone', 'GabeN', 'HailHelix'];

View file

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

View file

@ -26,7 +26,9 @@ void main() {
expect(user.id, '88888888');
expect(user.login, 'bob');
expect(user.displayName, 'Bob');
expect(user.profileImageUrl,
'https://static-cdn.jtvnw.net/user-default-pictures-uv/ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png');
expect(
user.profileImageUrl,
'https://static-cdn.jtvnw.net/user-default-pictures-uv/ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png',
);
});
}