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, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/**.g.dart
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
@ -31,6 +34,6 @@ linter:
- avoid_redundant_argument_values - avoid_redundant_argument_values
- unnecessary_parenthesis - unnecessary_parenthesis
- always_declare_return_types - always_declare_return_types
- require_trailing_commas
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # 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:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item> <item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item> <item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your 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 - Flutter
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
- Flutter - Flutter
- Firebase/Analytics (10.10.0): - Firebase/Analytics (10.15.0):
- Firebase/Core - Firebase/Core
- Firebase/Core (10.10.0): - Firebase/Core (10.15.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseAnalytics (~> 10.10.0) - FirebaseAnalytics (~> 10.15.0)
- Firebase/CoreOnly (10.10.0): - Firebase/CoreOnly (10.15.0):
- FirebaseCore (= 10.10.0) - FirebaseCore (= 10.15.0)
- Firebase/Crashlytics (10.10.0): - Firebase/Crashlytics (10.15.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebaseCrashlytics (~> 10.10.0) - FirebaseCrashlytics (~> 10.15.0)
- Firebase/Performance (10.10.0): - Firebase/Performance (10.15.0):
- Firebase/CoreOnly - Firebase/CoreOnly
- FirebasePerformance (~> 10.10.0) - FirebasePerformance (~> 10.15.0)
- firebase_analytics (10.4.3): - firebase_analytics (10.6.0):
- Firebase/Analytics (= 10.10.0) - Firebase/Analytics (= 10.15.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_core (2.14.0): - firebase_core (2.18.0):
- Firebase/CoreOnly (= 10.10.0) - Firebase/CoreOnly (= 10.15.0)
- Flutter - Flutter
- firebase_crashlytics (3.3.3): - firebase_crashlytics (3.4.0):
- Firebase/Crashlytics (= 10.10.0) - Firebase/Crashlytics (= 10.15.0)
- firebase_core - firebase_core
- Flutter - Flutter
- firebase_performance (0.9.2-3): - firebase_performance (0.9.3):
- Firebase/Performance (= 10.10.0) - Firebase/Performance (= 10.15.0)
- firebase_core - firebase_core
- Flutter - Flutter
- FirebaseABTesting (10.11.0): - FirebaseABTesting (10.16.0):
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
- FirebaseAnalytics (10.10.0): - FirebaseAnalytics (10.15.0):
- FirebaseAnalytics/AdIdSupport (= 10.10.0) - FirebaseAnalytics/AdIdSupport (= 10.15.0)
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 10.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11) - GoogleUtilities/AppDelegateSwizzler (~> 7.11)
@ -42,24 +42,24 @@ PODS:
- GoogleUtilities/Network (~> 7.11) - GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)" - "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseAnalytics/AdIdSupport (10.10.0): - FirebaseAnalytics/AdIdSupport (10.15.0):
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 10.0)
- GoogleAppMeasurement (= 10.10.0) - GoogleAppMeasurement (= 10.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11) - GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11) - GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11) - GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)" - "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseCore (10.10.0): - FirebaseCore (10.15.0):
- FirebaseCoreInternal (~> 10.0) - FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8) - GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreExtension (10.11.0): - FirebaseCoreExtension (10.16.0):
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
- FirebaseCoreInternal (10.11.0): - FirebaseCoreInternal (10.16.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)" - "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseCrashlytics (10.10.0): - FirebaseCrashlytics (10.15.0):
- FirebaseCore (~> 10.5) - FirebaseCore (~> 10.5)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 10.0)
- FirebaseSessions (~> 10.5) - FirebaseSessions (~> 10.5)
@ -67,12 +67,12 @@ PODS:
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (~> 2.1) - PromisesObjC (~> 2.1)
- FirebaseInstallations (10.11.0): - FirebaseInstallations (10.16.0):
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1) - PromisesObjC (~> 2.1)
- FirebasePerformance (10.10.0): - FirebasePerformance (10.15.0):
- FirebaseCore (~> 10.5) - FirebaseCore (~> 10.5)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfig (~> 10.0) - FirebaseRemoteConfig (~> 10.0)
@ -82,13 +82,13 @@ PODS:
- GoogleUtilities/ISASwizzler (~> 7.8) - GoogleUtilities/ISASwizzler (~> 7.8)
- GoogleUtilities/MethodSwizzler (~> 7.8) - GoogleUtilities/MethodSwizzler (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (< 2.30910.0, >= 2.30908.0)
- FirebaseRemoteConfig (10.11.0): - FirebaseRemoteConfig (10.16.0):
- FirebaseABTesting (~> 10.0) - FirebaseABTesting (~> 10.0)
- FirebaseCore (~> 10.0) - FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 10.0)
- GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Environment (~> 7.8)
- "GoogleUtilities/NSData+zlib (~> 7.8)" - "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseSessions (10.11.0): - FirebaseSessions (10.16.0):
- FirebaseCore (~> 10.5) - FirebaseCore (~> 10.5)
- FirebaseCoreExtension (~> 10.0) - FirebaseCoreExtension (~> 10.0)
- FirebaseInstallations (~> 10.0) - FirebaseInstallations (~> 10.0)
@ -104,49 +104,49 @@ PODS:
- FMDB (2.7.5): - FMDB (2.7.5):
- FMDB/standard (= 2.7.5) - FMDB/standard (= 2.7.5)
- FMDB/standard (2.7.5) - FMDB/standard (2.7.5)
- GoogleAppMeasurement (10.10.0): - GoogleAppMeasurement (10.15.0):
- GoogleAppMeasurement/AdIdSupport (= 10.10.0) - GoogleAppMeasurement/AdIdSupport (= 10.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11) - GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11) - GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11) - GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)" - "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleAppMeasurement/AdIdSupport (10.10.0): - GoogleAppMeasurement/AdIdSupport (10.15.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.10.0) - GoogleAppMeasurement/WithoutAdIdSupport (= 10.15.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11) - GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11) - GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11) - GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)" - "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleAppMeasurement/WithoutAdIdSupport (10.10.0): - GoogleAppMeasurement/WithoutAdIdSupport (10.15.0):
- GoogleUtilities/AppDelegateSwizzler (~> 7.11) - GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11) - GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11) - GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)" - "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (< 2.30910.0, >= 2.30908.0)
- GoogleDataTransport (9.2.3): - GoogleDataTransport (9.2.5):
- GoogleUtilities/Environment (~> 7.7) - GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0) - nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2) - PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/AppDelegateSwizzler (7.11.1): - GoogleUtilities/AppDelegateSwizzler (7.11.5):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Network - GoogleUtilities/Network
- GoogleUtilities/Environment (7.11.1): - GoogleUtilities/Environment (7.11.5):
- PromisesObjC (< 3.0, >= 1.2) - PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/ISASwizzler (7.11.1) - GoogleUtilities/ISASwizzler (7.11.5)
- GoogleUtilities/Logger (7.11.1): - GoogleUtilities/Logger (7.11.5):
- GoogleUtilities/Environment - GoogleUtilities/Environment
- GoogleUtilities/MethodSwizzler (7.11.1): - GoogleUtilities/MethodSwizzler (7.11.5):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Network (7.11.1): - GoogleUtilities/Network (7.11.5):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib" - "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability - GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.11.1)" - "GoogleUtilities/NSData+zlib (7.11.5)"
- GoogleUtilities/Reachability (7.11.1): - GoogleUtilities/Reachability (7.11.5):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.11.1): - GoogleUtilities/UserDefaults (7.11.5):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- in_app_review (0.2.0): - in_app_review (0.2.0):
- Flutter - Flutter
@ -160,9 +160,9 @@ PODS:
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- PromisesObjC (2.2.0) - PromisesObjC (2.3.1)
- PromisesSwift (2.2.0): - PromisesSwift (2.3.1):
- PromisesObjC (= 2.2.0) - PromisesObjC (= 2.3.1)
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@ -171,7 +171,7 @@ PODS:
- FMDB (>= 2.7.5) - FMDB (>= 2.7.5)
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- wakelock (0.0.1): - wakelock_plus (0.0.1):
- Flutter - Flutter
- webview_flutter_wkwebview (0.0.1): - webview_flutter_wkwebview (0.0.1):
- Flutter - Flutter
@ -192,7 +192,7 @@ DEPENDENCIES:
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/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`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS: SPEC REPOS:
@ -247,48 +247,48 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite/ios" :path: ".symlinks/plugins/sqflite/ios"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock: wakelock_plus:
:path: ".symlinks/plugins/wakelock/ios" :path: ".symlinks/plugins/wakelock_plus/ios"
webview_flutter_wkwebview: webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios" :path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8 advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
Firebase: facd334e557a979bd03a0b58d90fd56b52b8aba0 Firebase: 66043bd4579e5b73811f96829c694c7af8d67435
firebase_analytics: 1a5ad75876257318ba5fdc6bf7aae73b6e98d0cf firebase_analytics: 6b016f3d8e86fec024f1a495834ae30d44fe4fe2
firebase_core: 85b6664038311940ad60584eaabc73103c61f5de firebase_core: 49fd2395e3fbbf63f453888c8e9f42f541f4c11c
firebase_crashlytics: cd12245f604099a4ef6557fb36490abe309a118a firebase_crashlytics: af0e347e4ab14d45da977c485568b0f0f1e697bb
firebase_performance: 1e6496ae6464861c0ed1bcda8c0d9b7440469502 firebase_performance: 1ecc6dc781c1cc9be43837ca5b9cdc517b55dfe6
FirebaseABTesting: 3f6b711718feb87346dc05f8c964601bc8fb2cf6 FirebaseABTesting: 03f0a8b88cf618350527f2c6a2234e29b9c65064
FirebaseAnalytics: 7bc7de519111dae802f5bc0c9c083918f8b8870d FirebaseAnalytics: 47cef43728f81a839cf1306576bdd77ffa2eac7e
FirebaseCore: d027ff503d37edb78db98429b11f580a24a7df2a FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e
FirebaseCoreExtension: cacdad57fdb60e0b86dcbcac058ec78237946759 FirebaseCoreExtension: 2dbc745b337eb99d2026a7a309ae037bd873f45e
FirebaseCoreInternal: 9e46c82a14a3b3a25be4e1e151ce6d21536b89c0 FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a
FirebaseCrashlytics: bc8f3bed7f4d04be79d87391455efb4c9b163131 FirebaseCrashlytics: a83f26fb922a3fe181eb738fb4dcf0c92bba6455
FirebaseInstallations: 2a2c6859354cbec0a228a863d4daf6de7c74ced4 FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee
FirebasePerformance: 7ae687b2d6e9656ecd62847053d61adcc96ba544 FirebasePerformance: b7988bc06c87e9c3b9e6b1ae854cd460b646ebd6
FirebaseRemoteConfig: 5f907cd5cf6f64d026c8bfd196a1086cbe1e95e2 FirebaseRemoteConfig: 17ec974c6cac5cdc6cf8297062c2219851857f06
FirebaseSessions: a62ba5c45284adb7714f4126cfbdb32b17c260bd FirebaseSessions: 96e7781e545929cde06dd91088ddbb0841391b43
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
GoogleAppMeasurement: bbbfd4bcb2b40ae9b772c3b0823a58c1e3d618f9 GoogleAppMeasurement: 722db6550d1e6d552b08398b69a975ac61039338
GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749 GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
PromisesSwift: cf9eb58666a43bbe007302226e510b16c1e10959 PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
COCOAPODS: 1.11.3 COCOAPODS: 1.13.0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:advanced_in_app_review/advanced_in_app_review.dart'; import 'package:advanced_in_app_review/advanced_in_app_review.dart';
import 'package:firebase_core/firebase_core.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/onboarding/onboarding_intro.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/theme.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -91,124 +91,6 @@ void main() async {
// Navigator key for sleep timer. Allows navigation popping without context. // Navigator key for sleep timer. Allows navigation popping without context.
final navigatorKey = GlobalKey<NavigatorState>(); 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 { class MyApp extends StatefulWidget {
final bool firstRun; final bool firstRun;
@ -239,14 +121,14 @@ class _MyAppState extends State<MyApp> {
return Observer( return Observer(
builder: (context) { builder: (context) {
final settingsStore = context.read<SettingsStore>(); final settingsStore = context.read<SettingsStore>();
final themes = FrostyThemes();
return MaterialApp( return MaterialApp(
title: 'Frosty', title: 'Frosty',
theme: lightTheme, theme: themes.light,
darkTheme: settingsStore.themeType == ThemeType.dark || darkTheme: settingsStore.themeType == ThemeType.black
settingsStore.themeType == ThemeType.system ? themes.black
? darkTheme : themes.dark,
: oledTheme,
themeMode: settingsStore.themeType == ThemeType.system themeMode: settingsStore.themeType == ThemeType.system
? ThemeMode.system ? ThemeMode.system
: settingsStore.themeType == ThemeType.light : settingsStore.themeType == ThemeType.light

View file

@ -152,5 +152,19 @@ enum BadgeType {
twitch, twitch,
bttv, bttv,
ffz, 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); 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 { enum EmoteType {
twitchBits, twitchBits,
twitchFollower, twitchFollower,
@ -213,5 +197,37 @@ enum EmoteType {
bttvChannel, bttvChannel,
bttvShared, bttvShared,
sevenTVGlobal, 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:collection/collection.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:frosty/constants.dart'; import 'package:frosty/constants.dart';
import 'package:frosty/models/badges.dart'; import 'package:frosty/models/badges.dart';
@ -46,7 +47,8 @@ class IRCMessage {
messages.clear(); messages.clear();
bufferedMessages.clear(); bufferedMessages.clear();
messages.add( messages.add(
IRCMessage.createNotice(message: 'Chat was cleared by a moderator')); IRCMessage.createNotice(message: 'Chat was cleared by a moderator'),
);
return; return;
} }
@ -106,7 +108,8 @@ class IRCMessage {
} }
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [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 TextStyle? style,
required ChatAssetsStore assetsStore, required ChatAssetsStore assetsStore,
required double badgeScale, required double badgeScale,
@ -191,9 +194,11 @@ class IRCMessage {
span.add( span.add(
_createBadgeSpan( _createBadgeSpan(
context,
badge: newBadge, badge: newBadge,
size: badgeSize, size: badgeSize,
backgroundColor: const Color(0xFF00AD03), backgroundColor: const Color(0xFF00AD03),
launchExternal: launchExternal,
), ),
); );
span.add(const TextSpan(text: ' ')); span.add(const TextSpan(text: ' '));
@ -215,8 +220,10 @@ class IRCMessage {
span.add( span.add(
_createBadgeSpan( _createBadgeSpan(
context,
badge: newBadge, badge: newBadge,
size: badgeSize, size: badgeSize,
launchExternal: launchExternal,
), ),
); );
span.add(const TextSpan(text: ' ')); span.add(const TextSpan(text: ' '));
@ -234,9 +241,11 @@ class IRCMessage {
span.insert( span.insert(
0, 0,
_createBadgeSpan( _createBadgeSpan(
context,
badge: badge, badge: badge,
size: badgeSize, size: badgeSize,
backgroundColor: color, backgroundColor: color,
launchExternal: launchExternal,
), ),
); );
span.add(const TextSpan(text: ' ')); span.add(const TextSpan(text: ' '));
@ -244,9 +253,11 @@ class IRCMessage {
} else { } else {
span.add( span.add(
_createBadgeSpan( _createBadgeSpan(
context,
badge: badge, badge: badge,
size: badgeSize, size: badgeSize,
backgroundColor: color, backgroundColor: color,
launchExternal: launchExternal,
), ),
); );
span.add(const TextSpan(text: ' ')); span.add(const TextSpan(text: ' '));
@ -259,9 +270,11 @@ class IRCMessage {
if (userBTTVBadge != null) { if (userBTTVBadge != null) {
span.add( span.add(
_createBadgeSpan( _createBadgeSpan(
context,
badge: userBTTVBadge, badge: userBTTVBadge,
size: badgeSize, size: badgeSize,
isSvg: true, isSvg: true,
launchExternal: launchExternal,
), ),
); );
span.add(const TextSpan(text: ' ')); span.add(const TextSpan(text: ' '));
@ -273,8 +286,10 @@ class IRCMessage {
for (final badge in user7TVBadges) { for (final badge in user7TVBadges) {
span.add( span.add(
_createBadgeSpan( _createBadgeSpan(
context,
badge: badge, badge: badge,
size: badgeSize, size: badgeSize,
launchExternal: launchExternal,
), ),
); );
span.add(const TextSpan(text: ' ')); span.add(const TextSpan(text: ' '));
@ -282,7 +297,8 @@ class IRCMessage {
} }
var color = Color( var color = Color(
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF'))); int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')),
);
if (useReadableColors) { if (useReadableColors) {
final hsl = HSLColor.fromColor(color); final hsl = HSLColor.fromColor(color);
@ -385,59 +401,45 @@ class IRCMessage {
: emoteSize, : emoteSize,
useFade: false, useFade: false,
), ),
) ),
]; ];
// Create the message for the tooltip // Create the message for the tooltip
final message = emoteStack.reversed.map( final message = emoteStack.reversed.map(
(emote) => '${emote.name} - ${emoteType[emote.type.index]}'); (emote) => emote.name,
);
final emoji = words[index];
localSpan.add( localSpan.add(
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: Tooltip( child: InkWell(
triggerMode: TooltipTriggerMode.tap, onTap: () => _showDetailsBottomSheet(
showDuration: const Duration(seconds: 3), context,
richMessage: WidgetSpan( leading: Stack(
child: Column( alignment: AlignmentDirectional.center,
children: [ children: [
Stack( if (nextWordIsEmoji)
alignment: AlignmentDirectional.center, Text(
children: [ emoji,
if (nextWordIsEmoji) style: textStyle?.copyWith(fontSize: 40),
Text( ),
words[index], ...emoteStack.reversed.map(
style: textStyle?.copyWith(fontSize: 75), (emote) => FrostyCachedNetworkImage(
), imageUrl: emote.url,
...emoteStack.reversed.map( width: 56,
(emote) => FrostyCachedNetworkImage( ),
imageUrl: emote.url,
height: 80,
useFade: false,
),
)
],
),
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( child: Stack(
alignment: AlignmentDirectional.center, alignment: AlignmentDirectional.center,
children: children, 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 the next word is neither an emote or emoji, add it as a text span.
if (nextEmote == null && !nextWordIsEmoji) { if (nextEmote == null && !nextWordIsEmoji) {
localSpan.add(const TextSpan(text: ' ')); localSpan.add(const TextSpan(text: ' '));
localSpan.add(_createTextSpan( localSpan.add(
_createTextSpan(
text: words[index], text: words[index],
style: textStyle, style: textStyle,
launchExternal: launchExternal)); launchExternal: launchExternal,
),
);
} }
} else { } else {
localSpan.add( localSpan.add(
_createEmoteSpan( _createEmoteSpan(
context,
emote: emote, emote: emote,
height: emote.height != null height: emote.height != null
? emote.height! * emoteScale ? emote.height! * emoteScale
: emoteSize, : emoteSize,
width: emote.width != null ? emote.width! * emoteScale : null, width: emote.width != null ? emote.width! * emoteScale : null,
launchExternal: launchExternal,
), ),
); );
} }
} else { } else {
if (regexEmoji.hasMatch(word)) { if (regexEmoji.hasMatch(word)) {
localSpan.add(_createEmojiSpan( localSpan.add(
_createEmojiSpan(
emoji: word, emoji: word,
style: textStyle?.copyWith(fontSize: emoteSize - 5))); style: textStyle?.copyWith(fontSize: emoteSize - 5),
),
);
} else { } else {
localSpan.add(_createTextSpan( localSpan.add(
_createTextSpan(
text: word, text: word,
style: textStyle, style: textStyle,
launchExternal: launchExternal)); launchExternal: launchExternal,
),
);
} }
} }
@ -505,7 +518,7 @@ class IRCMessage {
static Widget _createBadgeWidget({ static Widget _createBadgeWidget({
required ChatBadge badge, required ChatBadge badge,
required double size, double? size,
Color? backgroundColor, Color? backgroundColor,
bool? isSvg, bool? isSvg,
}) { }) {
@ -535,35 +548,30 @@ class IRCMessage {
} }
} }
static WidgetSpan _createBadgeSpan({ static WidgetSpan _createBadgeSpan(
BuildContext context, {
required ChatBadge badge, required ChatBadge badge,
required double size, required double size,
required bool launchExternal,
Color? backgroundColor, Color? backgroundColor,
bool? isSvg, bool? isSvg,
}) { }) {
return WidgetSpan( return WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: Tooltip( child: InkWell(
triggerMode: TooltipTriggerMode.tap, onTap: () => _showDetailsBottomSheet(
showDuration: const Duration(seconds: 3), context,
richMessage: WidgetSpan( leading: _createBadgeWidget(
child: Column( badge: badge,
children: [ backgroundColor: backgroundColor,
_createBadgeWidget( isSvg: isSvg,
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( child: _createBadgeWidget(
badge: badge, badge: badge,
size: size, size: size,
@ -574,37 +582,27 @@ class IRCMessage {
); );
} }
static WidgetSpan _createEmoteSpan({ static WidgetSpan _createEmoteSpan(
BuildContext context, {
required Emote emote, required Emote emote,
required double height, required double height,
required double? width, required double? width,
required bool launchExternal,
}) { }) {
return WidgetSpan( return WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: Tooltip( child: InkWell(
triggerMode: TooltipTriggerMode.tap, onTap: () => _showDetailsBottomSheet(
showDuration: const Duration(seconds: 3), context,
richMessage: WidgetSpan( leading: FrostyCachedNetworkImage(
child: Column( imageUrl: emote.url,
children: [ width: 56,
FrostyCachedNetworkImage(
imageUrl: emote.url,
height: 80,
useFade: false,
),
const SizedBox(height: 5.0),
Text(
emote.name,
style: const TextStyle(color: Colors.white),
),
Text(
emoteType[emote.type.index],
style: const TextStyle(color: Colors.white),
),
],
), ),
url: emote.url,
title: emote.name,
subtitle: Text(emote.type.toString()),
launchExternal: launchExternal,
), ),
preferBelow: false,
child: FrostyCachedNetworkImage( child: FrostyCachedNetworkImage(
imageUrl: emote.url, imageUrl: emote.url,
height: height, height: height,
@ -616,26 +614,96 @@ class IRCMessage {
); );
} }
static TextSpan _createTextSpan( static TextSpan _createTextSpan({
{required String text, required bool launchExternal, TextStyle? style}) { required String text,
required bool launchExternal,
TextStyle? style,
}) {
if (text.startsWith('@')) { if (text.startsWith('@')) {
return TextSpan( 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)) { } else if (RegExp(r'https?:\/\/').hasMatch(text)) {
return TextSpan( return TextSpan(
text: text, text: text,
style: style?.copyWith(color: Colors.blue), style: style?.copyWith(color: Colors.blue),
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(text), ..onTap = () => launchUrl(
mode: launchExternal Uri.parse(text),
? LaunchMode.externalApplication mode: launchExternal
: LaunchMode.inAppWebView), ? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
),
); );
} else { } else {
return TextSpan(text: text, style: style); 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. /// Parses an IRC string and returns its corresponding [IRCMessage] object.
factory IRCMessage.fromString(String whole, {String? userLogin}) { factory IRCMessage.fromString(String whole, {String? userLogin}) {
// We have three parts: // We have three parts:
@ -713,7 +781,9 @@ class IRCMessage {
final String range; final String range;
if (emoteIdAndPosition.contains(',')) { if (emoteIdAndPosition.contains(',')) {
range = emoteIdAndPosition.substring( range = emoteIdAndPosition.substring(
indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(',')); indexBetweenIdAndPositions + 1,
emoteIdAndPosition.indexOf(','),
);
} else { } else {
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1); range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1);
} }
@ -780,7 +850,8 @@ class IRCMessage {
if (wordBuffer.isNotEmpty) { if (wordBuffer.isNotEmpty) {
split.addAll( split.addAll(
wordBuffer.toString().split(' ').where((element) => element != '')); wordBuffer.toString().split(' ').where((element) => element != ''),
);
} }
if (emojiBuffer.isNotEmpty) split.add(emojiBuffer.toString()); 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/seventv_api.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/constants.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/chat.dart';
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart'; import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
import 'package:frosty/screens/channel/chat/stores/chat_assets_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/channel/video/video_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_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/app_bar.dart';
import 'package:frosty/widgets/notification.dart'; import 'package:frosty/widgets/notification.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -171,7 +171,7 @@ class _VideoChatState extends State<VideoChat> {
child: IgnorePointer( child: IgnorePointer(
ignoring: !videoBarVisible, ignoring: !videoBarVisible,
child: ColoredBox( child: ColoredBox(
color: Theme.of(context).canvasColor, color: Theme.of(context).scaffoldBackgroundColor,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -180,7 +180,7 @@ class _VideoChatState extends State<VideoChat> {
streamInfo: _videoStore.streamInfo!, streamInfo: _videoStore.streamInfo!,
tappableCategory: false, tappableCategory: false,
), ),
const Divider(height: 1, thickness: 1), const Divider(),
], ],
), ),
), ),
@ -235,7 +235,8 @@ class _VideoChatState extends State<VideoChat> {
_chatStore.settings.chatWidth, _chatStore.settings.chatWidth,
color: _chatStore.settings.fullScreen color: _chatStore.settings.fullScreen
? Colors.black.withOpacity( ? Colors.black.withOpacity(
_chatStore.settings.fullScreenChatOverlayOpacity) _chatStore.settings.fullScreenChatOverlayOpacity,
)
: Theme.of(context).scaffoldBackgroundColor, : Theme.of(context).scaffoldBackgroundColor,
child: chat, child: chat,
); );
@ -244,7 +245,7 @@ class _VideoChatState extends State<VideoChat> {
visible: settingsStore.fullScreenChatOverlay, visible: settingsStore.fullScreenChatOverlay,
maintainState: true, maintainState: true,
child: Theme( child: Theme(
data: darkTheme, data: FrostyThemes().dark,
child: DefaultTextStyle( child: DefaultTextStyle(
style: DefaultTextStyle.of(context) style: DefaultTextStyle.of(context)
.style .style
@ -282,7 +283,7 @@ class _VideoChatState extends State<VideoChat> {
children: settingsStore.landscapeChatLeftSide children: settingsStore.landscapeChatLeftSide
? [overlayChat, Expanded(child: overlay)] ? [overlayChat, Expanded(child: overlay)]
: [Expanded(child: overlay), overlayChat], : [Expanded(child: overlay), overlayChat],
) ),
], ],
) )
: Row( : Row(

View file

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.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/stores/chat_store.dart';
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.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/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/button.dart';
import 'package:frosty/widgets/page_view.dart'; import 'package:frosty/widgets/page_view.dart';
class Chat extends StatelessWidget { class Chat extends StatelessWidget {
@ -35,31 +35,35 @@ class Chat extends StatelessWidget {
children: [ children: [
MediaQuery( MediaQuery(
data: MediaQuery.of(context).copyWith( data: MediaQuery.of(context).copyWith(
textScaleFactor: chatStore.settings.messageScale), textScaleFactor: chatStore.settings.messageScale,
),
child: DefaultTextStyle( child: DefaultTextStyle(
style: DefaultTextStyle.of(context) style: DefaultTextStyle.of(context)
.style .style
.copyWith(fontSize: chatStore.settings.fontSize), .copyWith(fontSize: chatStore.settings.fontSize),
child: Observer( child: Scrollbar(
builder: (context) { controller: chatStore.scrollController,
return ListView.builder( child: Observer(
reverse: true, builder: (context) {
padding: EdgeInsets.zero, return ListView.builder(
addAutomaticKeepAlives: false, reverse: true,
controller: chatStore.scrollController, padding: EdgeInsets.zero,
itemCount: chatStore.renderMessages.length, addAutomaticKeepAlives: false,
itemBuilder: (context, index) => ChatMessage( controller: chatStore.scrollController,
ircMessage: chatStore.renderMessages.reversed itemCount: chatStore.renderMessages.length,
.toList()[index], itemBuilder: (context, index) => ChatMessage(
chatStore: chatStore, ircMessage: chatStore.renderMessages.reversed
), .toList()[index],
); chatStore: chatStore,
}, ),
);
},
),
), ),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.all(5.0), padding: const EdgeInsets.all(4),
child: Observer( child: Observer(
builder: (_) => AnimatedSwitcher( builder: (_) => AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@ -67,13 +71,20 @@ class Chat extends StatelessWidget {
switchOutCurve: Curves.easeIn, switchOutCurve: Curves.easeIn,
child: chatStore.autoScroll child: chatStore.autoScroll
? null ? null
: Button( : ElevatedButton.icon(
padding: const EdgeInsets.symmetric(
horizontal: 15.0, vertical: 10.0),
onPressed: chatStore.resumeScroll, onPressed: chatStore.resumeScroll,
icon: const Icon( icon:
Icons.keyboard_double_arrow_down_rounded), const Icon(Icons.arrow_downward_rounded),
child: const Text('Resume scroll'), label: Text(
chatStore.messageBuffer.isNotEmpty
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
: 'Resume scroll',
style: const TextStyle(
fontFeatures: [
FontFeature.tabularFigures(),
],
),
),
), ),
), ),
), ),
@ -111,31 +122,38 @@ class Chat extends StatelessWidget {
switchInCurve: Curves.easeOut, switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn, switchOutCurve: Curves.easeIn,
child: chatStore.assetsStore.showEmoteMenu child: chatStore.assetsStore.showEmoteMenu
? FrostyPageView( ? Column(
headers: const [
'Recent',
'Twitch',
'7TV',
'BTTV',
'FFZ',
],
children: [ children: [
RecentEmotesPanel( const Divider(),
chatStore: chatStore, Expanded(
), child: FrostyPageView(
EmoteMenuPanel( headers: const [
chatStore: chatStore, 'Recent',
twitchEmotes: chatStore 'Twitch',
.assetsStore.userEmoteSectionToEmotes, '7TV',
), 'BTTV',
...[ 'FFZ',
chatStore.assetsStore.sevenTVEmotes, ],
chatStore.assetsStore.bttvEmotes, children: [
chatStore.assetsStore.ffzEmotes RecentEmotesPanel(
].map( chatStore: chatStore,
(emotes) => EmoteMenuPanel( ),
chatStore: chatStore, EmoteMenuPanel(
emotes: emotes, chatStore: chatStore,
twitchEmotes: chatStore
.assetsStore.userEmoteSectionToEmotes,
),
...[
chatStore.assetsStore.sevenTVEmotes,
chatStore.assetsStore.bttvEmotes,
chatStore.assetsStore.ffzEmotes,
].map(
(emotes) => EmoteMenuPanel(
chatStore: chatStore,
emotes: emotes,
),
),
],
), ),
), ),
], ],

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/details/chat_users_list.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
import 'package:frosty/screens/settings/settings.dart'; import 'package:frosty/screens/settings/settings.dart';
import 'package:frosty/widgets/bottom_sheet.dart'; import 'package:frosty/widgets/section_header.dart';
import 'package:frosty/widgets/button.dart'; import 'package:intl/intl.dart';
import 'package:frosty/widgets/dialog.dart';
import 'package:frosty/widgets/list_tile.dart';
class ChatDetails extends StatelessWidget { class ChatDetails extends StatelessWidget {
final ChatDetailsStore chatDetailsStore; final ChatDetailsStore chatDetailsStore;
@ -25,90 +23,97 @@ class ChatDetails extends StatelessWidget {
required this.userLogin, required this.userLogin,
}) : super(key: key); }) : super(key: key);
Future<void> _showSleepTimerDialog(BuildContext context) { String formatDuration(Duration duration) {
return showDialog( 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, context: context,
builder: (context) => FrostyDialog( builder: (context) => Column(
title: 'Sleep Timer', crossAxisAlignment: CrossAxisAlignment.start,
content: Observer( children: [
builder: (context) => Column( const SectionHeader(
mainAxisSize: MainAxisSize.min, 'Sleep timer',
children: [ padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
Opacity(
opacity: chatStore.sleepTimer != null &&
chatStore.sleepTimer!.isActive
? 1.0
: 0.5,
child: Row(
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'),
],
),
],
), ),
), Expanded(
actions: [ child: ListView(
Observer( children: [
builder: (context) => Button( if (chatStore.sleepTimer?.isActive == true)
onPressed: Observer(
chatStore.sleepHours == 0 && chatStore.sleepMinutes == 0 builder: (context) {
? null return ListTile(
: () => chatStore.updateSleepTimer( leading: const Icon(Icons.close_rounded),
onTimerFinished: () => navigatorKey.currentState title: Text(
?.popUntil((route) => route.isFirst), 'Turn off (${formatTimeLeft(chatStore.timeRemaining)})',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
), ),
child: const Text('Set timer'), ),
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),
);
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) { Future<void> _showClearDialog(BuildContext context) {
return showDialog( return showDialog(
context: context, context: context,
builder: (context) => FrostyDialog( builder: (context) => AlertDialog.adaptive(
title: 'Clear Recent Emotes', title: const Text('Clear recent emotes'),
message: 'Are you sure you want to clear your recent emotes?', content:
const Text('Are you sure you want to clear your recent emotes?'),
actions: [ actions: [
Button( TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: Navigator.of(context).pop, onPressed: Navigator.of(context).pop,
child: const Text('Yes'), child: const Text('Yes'),
), ),
Button(
onPressed: Navigator.of(context).pop,
color: Colors.grey,
child: const Text('Cancel'),
),
], ],
), ),
); );
@ -138,62 +143,76 @@ class ChatDetails extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final children = [ final children = [
const SectionHeader(
'Chat modes',
padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
),
ListTile( ListTile(
title: ChatModes(roomState: chatDetailsStore.roomState), title: ChatModes(roomState: chatDetailsStore.roomState),
), ),
FrostyListTile( const SectionHeader(
'More',
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
),
ListTile(
leading: const Icon(Icons.people_outline), leading: const Icon(Icons.people_outline),
title: 'Chatters', title: const Text('Chatters'),
onTap: () => showModalBottomSheet( onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => FrostyBottomSheet( builder: (context) => SizedBox(
child: SizedBox( height: MediaQuery.of(context).size.height * 0.8,
height: MediaQuery.of(context).size.height * 0.8, child: GestureDetector(
child: GestureDetector( onTap: FocusScope.of(context).unfocus,
onTap: FocusScope.of(context).unfocus, child: ChattersList(
child: ChattersList( chatDetailsStore: chatDetailsStore,
chatDetailsStore: chatDetailsStore, chatStore: chatStore,
chatStore: chatStore, userLogin: userLogin,
userLogin: userLogin,
),
), ),
), ),
), ),
), ),
), ),
FrostyListTile( Observer(
leading: const Icon(Icons.timer_outlined), builder: (context) {
title: 'Sleep timer', return ListTile(
onTap: () => _showSleepTimerDialog(context), leading: const Icon(Icons.timer_outlined),
title: Text(
'Sleep timer ${chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(chatStore.timeRemaining)})' : ''}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
onTap: () => _showSleepTimer(context),
);
},
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.delete_outline_rounded), leading: const Icon(Icons.delete_outline_rounded),
title: 'Clear recent emotes', title: const Text('Clear recent emotes'),
onTap: () => _showClearDialog(context), onTap: () => _showClearDialog(context),
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.refresh_rounded), leading: const Icon(Icons.refresh_rounded),
title: 'Reconnect to chat', title: const Text('Reconnect to chat'),
onTap: () { onTap: () {
chatStore.updateNotification('Reconnecting to chat...'); chatStore.updateNotification('Reconnecting to chat...');
chatStore.connectToChat(); chatStore.connectToChat();
}, },
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.refresh_rounded), leading: const Icon(Icons.refresh_rounded),
title: 'Refresh badges and emotes', title: const Text('Refresh badges and emotes'),
onTap: () async { onTap: () async {
await chatStore.getAssets(); await chatStore.getAssets();
chatStore.updateNotification('Badges and emotes refreshed'); chatStore.updateNotification('Badges and emotes refreshed');
}, },
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.settings_outlined), leading: const Icon(Icons.settings_outlined),
title: 'Settings', title: const Text('Settings'),
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -203,16 +222,10 @@ class ChatDetails extends StatelessWidget {
), ),
]; ];
return FrostyBottomSheet( return ListView(
child: MediaQuery.of(context).orientation == Orientation.landscape shrinkWrap: true,
? SizedBox( primary: false,
height: MediaQuery.of(context).size.height * 0.8, children: children,
child: ListView(
children: children,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: children),
); );
} }
} }

View file

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

View file

@ -20,18 +20,22 @@ class EmoteMenuPanel extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (emotes != null) { if (emotes != null) {
final globalEmotes = emotes! final globalEmotes = emotes!
.where((emote) => .where(
emote.type == EmoteType.bttvGlobal || (emote) =>
emote.type == EmoteType.ffzGlobal || emote.type == EmoteType.bttvGlobal ||
emote.type == EmoteType.sevenTVGlobal) emote.type == EmoteType.ffzGlobal ||
emote.type == EmoteType.sevenTVGlobal,
)
.toList(); .toList();
final channelEmotes = emotes! final channelEmotes = emotes!
.where((emote) => .where(
emote.type == EmoteType.bttvChannel || (emote) =>
emote.type == EmoteType.bttvShared || emote.type == EmoteType.bttvChannel ||
emote.type == EmoteType.ffzChannel || emote.type == EmoteType.bttvShared ||
emote.type == EmoteType.sevenTVChannel) emote.type == EmoteType.ffzChannel ||
emote.type == EmoteType.sevenTVChannel,
)
.toList(); .toList();
return FrostyPageView( 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:frosty/widgets/cached_image.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class EmoteMenuSection extends StatelessWidget { class EmoteMenuSection extends StatefulWidget {
final ChatStore chatStore; final ChatStore chatStore;
final List<Emote> emotes; final List<Emote> emotes;
@ -16,8 +16,16 @@ class EmoteMenuSection extends StatelessWidget {
required this.emotes, required this.emotes,
}) : super(key: key); }) : super(key: key);
@override
State<EmoteMenuSection> createState() => _EmoteMenuSectionState();
}
class _EmoteMenuSectionState extends State<EmoteMenuSection>
with AutomaticKeepAliveClientMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
return GridView.builder( return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount:
@ -28,23 +36,27 @@ class EmoteMenuSection extends StatelessWidget {
: 16, : 16,
), ),
itemBuilder: (context, index) => InkWell( itemBuilder: (context, index) => InkWell(
onTap: () => chatStore.addEmote(emotes[index]), onTap: () => widget.chatStore.addEmote(widget.emotes[index]),
child: Tooltip( child: Tooltip(
message: emotes[index].name, message: widget.emotes[index].name,
preferBelow: false, preferBelow: false,
child: Padding( child: Padding(
padding: const EdgeInsets.all(5.0), padding: const EdgeInsets.all(5.0),
child: Center( child: Center(
child: FrostyCachedNetworkImage( child: FrostyCachedNetworkImage(
imageUrl: emotes[index].url, imageUrl: widget.emotes[index].url,
height: emotes[index].height?.toDouble() ?? defaultEmoteSize, height:
width: emotes[index].width?.toDouble(), 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 emote = chatStore.assetsStore.recentEmotes[index];
final validEmotes = [ final validEmotes = [
...chatStore.assetsStore.emoteToObject.values, ...chatStore.assetsStore.emoteToObject.values,
...chatStore.assetsStore.userEmoteToObject.values ...chatStore.assetsStore.userEmoteToObject.values,
]; ];
final matchingEmotes = validEmotes.where((existingEmote) => final matchingEmotes = validEmotes.where(
existingEmote.name == emote.name && (existingEmote) =>
existingEmote.type == emote.type); existingEmote.name == emote.name &&
existingEmote.type == emote.type,
);
return InkWell( return InkWell(
onTap: matchingEmotes.isNotEmpty onTap: matchingEmotes.isNotEmpty
@ -52,7 +54,7 @@ class RecentEmotesPanel extends StatelessWidget {
message: emote.name, message: emote.name,
preferBelow: false, preferBelow: false,
child: Padding( child: Padding(
padding: const EdgeInsets.all(10.0), padding: const EdgeInsets.all(8),
child: Center( child: Center(
child: FrostyCachedNetworkImage( child: FrostyCachedNetworkImage(
imageUrl: matchingEmotes.isNotEmpty imageUrl: matchingEmotes.isNotEmpty

View file

@ -109,8 +109,10 @@ abstract class ChatAssetsStoreBase with Store {
_disposeReaction = autorun((_) { _disposeReaction = autorun((_) {
if (_recentEmotes.length > 48) _recentEmotes.removeLast(); if (_recentEmotes.length > 48) _recentEmotes.removeLast();
prefs.setStringList('recent_emotes', prefs.setStringList(
_recentEmotes.map((emote) => jsonEncode(emote)).toList()); 'recent_emotes',
_recentEmotes.map((emote) => jsonEncode(emote)).toList(),
);
}); });
} }
@ -162,8 +164,11 @@ abstract class ChatAssetsStoreBase with Store {
ffzRoomInfo = roomInfo; ffzRoomInfo = roomInfo;
return emotes; return emotes;
}).catchError(onError), }).catchError(onError),
]).then((assets) => assets.expand((list) => list)).then((emotes) => ]).then((assets) => assets.expand((list) => list)).then(
_emoteToObject = {for (final emote in emotes) emote.name: emote}); (emotes) => _emoteToObject = {
for (final emote in emotes) emote.name: emote,
},
);
@action @action
Future<void> badgesFuture({ Future<void> badgesFuture({
@ -177,10 +182,12 @@ abstract class ChatAssetsStoreBase with Store {
twitchApi twitchApi
.getBadgesGlobal(headers: headers) .getBadgesGlobal(headers: headers)
.then((badges) => twitchBadgesToObject.addAll(badges)) .then((badges) => twitchBadgesToObject.addAll(badges))
.then((_) => twitchApi .then(
.getBadgesChannel(id: channelId, headers: headers) (_) => twitchApi
.then((badges) => twitchBadgesToObject.addAll(badges)) .getBadgesChannel(id: channelId, headers: headers)
.catchError(onError)), .then((badges) => twitchBadgesToObject.addAll(badges))
.catchError(onError),
),
ffzApi ffzApi
.getBadges() .getBadges()
.then((badges) => _userToFFZBadges = badges) .then((badges) => _userToFFZBadges = badges)
@ -201,15 +208,21 @@ abstract class ChatAssetsStoreBase with Store {
required Map<String, String> headers, required Map<String, String> headers,
required Function onError, required Function onError,
}) async { }) async {
final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi final userEmotes = await Future.wait(
.getEmotesSets(setId: setId, headers: headers) emoteSets.map(
.catchError(onError))); (setId) => twitchApi
.getEmotesSets(setId: setId, headers: headers)
.catchError(onError),
),
);
for (final emoteSet in userEmotes) { for (final emoteSet in userEmotes) {
if (emoteSet.isNotEmpty) { if (emoteSet.isNotEmpty) {
if (emoteSet.first.type == EmoteType.twitchSub) { if (emoteSet.first.type == EmoteType.twitchSub) {
final owner = await twitchApi.getUser( final owner = await twitchApi.getUser(
id: emoteSet.first.ownerId, headers: headers); id: emoteSet.first.ownerId,
headers: headers,
);
_userEmoteSectionToEmotes.update( _userEmoteSectionToEmotes.update(
owner.displayName, owner.displayName,
(existingEmoteSet) => [...existingEmoteSet, ...emoteSet], (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/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:mobx/mobx.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'; import 'package:web_socket_channel/web_socket_channel.dart';
part 'chat_store.g.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. /// 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. /// 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 used for dismissing the notification.
Timer? _notificationTimer; Timer? _notificationTimer;
@ -88,14 +88,6 @@ abstract class ChatStoreBase with Store {
/// The current timer for the sleep timer if active. /// The current timer for the sleep timer if active.
Timer? sleepTimer; 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. /// The time remaining for the sleep timer.
@observable @observable
var timeRemaining = const Duration(); var timeRemaining = const Duration();
@ -156,7 +148,7 @@ abstract class ChatStoreBase with Store {
required this.displayName, required this.displayName,
}) { }) {
// Enable wakelock to prevent the chat from sleeping. // 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. // Create a reaction that will reconnect to chat when logging in or out.
// Closing the channel will trigger a reconnect with the new credentials. // 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. // Create a timer that will add messages from the buffer every 200 milliseconds.
_messageBufferTimer = Timer.periodic( _messageBufferTimer = Timer.periodic(
const Duration(milliseconds: 200), (timer) => addMessages()); const Duration(milliseconds: 200),
(timer) => addMessages(),
);
assetsStore.init(); assetsStore.init();
_messageBuffer messageBuffer
.add(IRCMessage.createNotice(message: 'Connecting to chat...')); .add(IRCMessage.createNotice(message: 'Connecting to chat...'));
if (settings.chatDelay > 0) { if (settings.chatDelay > 0) {
_messageBuffer.add(IRCMessage.createNotice( messageBuffer.add(
IRCMessage.createNotice(
message: 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(); connectToChat();
@ -242,8 +239,10 @@ abstract class ChatStoreBase with Store {
if (!_userState.mod && if (!_userState.mod &&
channelName != auth.user.details?.login && channelName != auth.user.details?.login &&
auth.user.blockedUsers auth.user.blockedUsers
.where((blockedUser) => .where(
blockedUser.userLogin == parsedIRCMessage.user) (blockedUser) =>
blockedUser.userLogin == parsedIRCMessage.user,
)
.isNotEmpty) { .isNotEmpty) {
continue; continue;
} }
@ -252,19 +251,19 @@ abstract class ChatStoreBase with Store {
case Command.privateMessage: case Command.privateMessage:
case Command.notice: case Command.notice:
case Command.userNotice: case Command.userNotice:
_messageBuffer.add(parsedIRCMessage); messageBuffer.add(parsedIRCMessage);
break; break;
case Command.clearChat: case Command.clearChat:
IRCMessage.clearChat( IRCMessage.clearChat(
messages: _messages, messages: _messages,
bufferedMessages: _messageBuffer, bufferedMessages: messageBuffer,
ircMessage: parsedIRCMessage, ircMessage: parsedIRCMessage,
); );
break; break;
case Command.clearMessage: case Command.clearMessage:
IRCMessage.clearMessage( IRCMessage.clearMessage(
messages: _messages, messages: _messages,
bufferedMessages: _messageBuffer, bufferedMessages: messageBuffer,
ircMessage: parsedIRCMessage, ircMessage: parsedIRCMessage,
); );
break; break;
@ -277,7 +276,7 @@ abstract class ChatStoreBase with Store {
if (toSend != null) { if (toSend != null) {
textController.clear(); textController.clear();
_messageBuffer.add(toSend!); messageBuffer.add(toSend!);
toSend = null; toSend = null;
} }
break; break;
@ -301,9 +300,9 @@ abstract class ChatStoreBase with Store {
if (!_autoScroll) { if (!_autoScroll) {
// While autoscroll is disabled, occasionally move messages from the buffer to the messages to prevent a memory leak. // While autoscroll is disabled, occasionally move messages from the buffer to the messages to prevent a memory leak.
if (_messageBuffer.length >= _messagesToRemove) { if (messageBuffer.length >= _messagesToRemove) {
_messages.addAll(_messageBuffer); _messages.addAll(messageBuffer);
_messageBuffer.clear(); messageBuffer.clear();
} }
} }
@ -315,9 +314,12 @@ abstract class ChatStoreBase with Store {
_channel?.sink.add('PONG :tmi.twitch.tv'); _channel?.sink.add('PONG :tmi.twitch.tv');
return; return;
} else if (message.contains('Welcome, GLHF!')) { } else if (message.contains('Welcome, GLHF!')) {
_messageBuffer.add(IRCMessage.createNotice( messageBuffer.add(
IRCMessage.createNotice(
message: message:
"Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!")); "Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!",
),
);
getAssets(); getAssets();
@ -365,8 +367,10 @@ abstract class ChatStoreBase with Store {
// Listen for new messages and forward them to the handler. // Listen for new messages and forward them to the handler.
_channelListener = _channel?.stream.listen( _channelListener = _channel?.stream.listen(
(data) => Future.delayed(Duration(seconds: settings.chatDelay.toInt()), (data) => Future.delayed(
() => _handleIRCData(data.toString())), Duration(seconds: settings.chatDelay.toInt()),
() => _handleIRCData(data.toString()),
),
onError: (error) => debugPrint('Chat error: ${error.toString()}'), onError: (error) => debugPrint('Chat error: ${error.toString()}'),
onDone: () async { onDone: () async {
if (_channel == null) return; 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. // Add notice that chat was disconnected and then wait the backoff time before reconnecting.
final notice = final notice =
'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...'; '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)); await Future.delayed(Duration(seconds: _backoffTime));
@ -385,8 +389,11 @@ abstract class ChatStoreBase with Store {
// Increment the retry count and attempt the reconnect. // Increment the retry count and attempt the reconnect.
_retries++; _retries++;
_messageBuffer.add(IRCMessage.createNotice( messageBuffer.add(
message: 'Reconnecting to chat (attempt $_retries)...')); IRCMessage.createNotice(
message: 'Reconnecting to chat (attempt $_retries)...',
),
);
_channelListener?.cancel(); _channelListener?.cancel();
connectToChat(); connectToChat();
}, },
@ -416,10 +423,10 @@ abstract class ChatStoreBase with Store {
@action @action
void addMessages() { void addMessages() {
if (!_autoScroll || _messageBuffer.isEmpty) return; if (!_autoScroll || messageBuffer.isEmpty) return;
_messages.addAll(_messageBuffer); _messages.addAll(messageBuffer);
_messageBuffer.clear(); messageBuffer.clear();
} }
/// Sends the given string message by the logged-in user and adds it to [_messages]. /// 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 (message.isEmpty) return;
if (_channel == null || _channel?.closeCode != null) { if (_channel == null || _channel?.closeCode != null) {
_messageBuffer.add(IRCMessage.createNotice( messageBuffer.add(
message: 'Failed to send message: disconnected from chat.')); IRCMessage.createNotice(
message: 'Failed to send message: disconnected from chat.',
),
);
} else { } else {
// Send the message to the IRC chat room. // Send the message to the IRC chat room.
_channel?.sink.add('PRIVMSG #$channelName :$message'); _channel?.sink.add('PRIVMSG #$channelName :$message');
@ -474,12 +484,15 @@ abstract class ChatStoreBase with Store {
} }
assetsStore.recentEmotes assetsStore.recentEmotes
..removeWhere((recentEmote) => ..removeWhere(
recentEmote.name == emote.name && recentEmote.type == emote.type) (recentEmote) =>
recentEmote.name == emote.name && recentEmote.type == emote.type,
)
..insert(0, emote); ..insert(0, emote);
textController.selection = TextSelection.fromPosition( 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]. /// 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); 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. /// Calls [onTimerFinished] when the sleep timer completes.
@action @action
void updateSleepTimer({required void Function() onTimerFinished}) { void updateSleepTimer({
// If hours and minutes are 0, do nothing. required Duration duration,
if (sleepHours == 0 && sleepMinutes == 0) return; required VoidCallback onTimerFinished,
}) {
// If there is an ongoing timer, cancel it since it'll be replaced. // If there is an ongoing timer, cancel it since it'll be replaced.
if (sleepTimer != null) cancelSleepTimer(); if (sleepTimer != null) cancelSleepTimer();
// Update the new time remaining // Update the new time remaining
timeRemaining = Duration(hours: sleepHours, minutes: sleepMinutes); timeRemaining = duration;
// Reset the hours and minutes in the dropdown buttons.
sleepHours = 0;
sleepMinutes = 0;
// Set a periodic timer that will update the time remaining every second. // Set a periodic timer that will update the time remaining every second.
sleepTimer = Timer.periodic( sleepTimer = Timer.periodic(
@ -564,6 +573,6 @@ abstract class ChatStoreBase with Store {
chatDetailsStore.dispose(); chatDetailsStore.dispose();
// Disable wakelock so that the sleep timer will function properly. // 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')) name: 'ChatStoreBase.renderMessages'))
.value; .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 = late final _$timeRemainingAtom =
Atom(name: 'ChatStoreBase.timeRemaining', context: context); Atom(name: 'ChatStoreBase.timeRemaining', context: context);
@ -316,11 +284,13 @@ mixin _$ChatStore on ChatStoreBase, Store {
} }
@override @override
void updateSleepTimer({required void Function() onTimerFinished}) { void updateSleepTimer(
{required Duration duration, required VoidCallback onTimerFinished}) {
final _$actionInfo = _$ChatStoreBaseActionController.startAction( final _$actionInfo = _$ChatStoreBaseActionController.startAction(
name: 'ChatStoreBase.updateSleepTimer'); name: 'ChatStoreBase.updateSleepTimer');
try { try {
return super.updateSleepTimer(onTimerFinished: onTimerFinished); return super.updateSleepTimer(
duration: duration, onTimerFinished: onTimerFinished);
} finally { } finally {
_$ChatStoreBaseActionController.endAction(_$actionInfo); _$ChatStoreBaseActionController.endAction(_$actionInfo);
} }
@ -340,8 +310,6 @@ mixin _$ChatStore on ChatStoreBase, Store {
@override @override
String toString() { String toString() {
return ''' return '''
sleepHours: ${sleepHours},
sleepMinutes: ${sleepMinutes},
timeRemaining: ${timeRemaining}, timeRemaining: ${timeRemaining},
expandChat: ${expandChat}, expandChat: ${expandChat},
renderMessages: ${renderMessages} renderMessages: ${renderMessages}

View file

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

View file

@ -5,6 +5,7 @@ import 'package:frosty/constants.dart';
import 'package:frosty/models/irc.dart'; import 'package:frosty/models/irc.dart';
import 'package:frosty/screens/channel/chat/stores/chat_store.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/chat_user_modal.dart';
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
class ChatMessage extends StatelessWidget { class ChatMessage extends StatelessWidget {
final IRCMessage ircMessage; final IRCMessage ircMessage;
@ -29,7 +30,6 @@ class ChatMessage extends StatelessWidget {
ircMessage.user == chatStore.auth.user.details?.login) return; ircMessage.user == chatStore.auth.user.details?.login) return;
showModalBottomSheet( showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => ChatUserModal( builder: (context) => ChatUserModal(
@ -69,6 +69,7 @@ class ChatMessage extends StatelessWidget {
final messageSpan = Text.rich( final messageSpan = Text.rich(
TextSpan( TextSpan(
children: ircMessage.generateSpan( children: ircMessage.generateSpan(
context,
onTapName: onTapName, onTapName: onTapName,
style: defaultTextStyle, style: defaultTextStyle,
assetsStore: chatStore.assetsStore, assetsStore: chatStore.assetsStore,
@ -98,19 +99,26 @@ class ChatMessage extends StatelessWidget {
Icon( Icon(
replyUser != null && replyBody != null replyUser != null && replyBody != null
? Icons.reply_rounded ? Icons.reply_rounded
: Icons.new_releases_outlined, : Icons.star_rounded,
size: defaultBadgeSize * chatStore.settings.badgeScale, size: defaultBadgeSize * chatStore.settings.badgeScale,
color: defaultTextStyle.color?.withOpacity(0.5), color: defaultTextStyle.color?.withOpacity(0.5),
textDirection: TextDirection.rtl, textDirection: TextDirection.rtl,
), ),
const SizedBox(width: 5.0), const SizedBox(width: 4),
Flexible( Flexible(
child: replyUser != null && replyBody != null child: replyUser != null && replyBody != null
? Tooltip( ? GestureDetector(
message: 'Replying to @$replyUser: $replyBody', onTap: isModal
preferBelow: false, ? null
triggerMode: TooltipTriggerMode.tap, : () => showModalBottomSheet(
showDuration: const Duration(seconds: 5), context: context,
builder: (context) {
return ReplyThread(
selectedMessage: ircMessage,
chatStore: chatStore,
);
},
),
child: Text( child: Text(
'Replying to @$replyUser: $replyBody', 'Replying to @$replyUser: $replyBody',
maxLines: 1, maxLines: 1,
@ -124,7 +132,7 @@ class ChatMessage extends StatelessWidget {
: Text( : Text(
'First time chatting', 'First time chatting',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
color: color:
defaultTextStyle.color?.withOpacity(0.5), defaultTextStyle.color?.withOpacity(0.5),
), ),
@ -132,7 +140,7 @@ class ChatMessage extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 5.0), const SizedBox(height: 4),
messageSpan, messageSpan,
], ],
); );
@ -155,22 +163,23 @@ class ChatMessage extends StatelessWidget {
if (ircMessage.command == Command.clearMessage) if (ircMessage.command == Command.clearMessage)
const Text( const Text(
'Message deleted', 'Message deleted',
style: TextStyle(fontWeight: FontWeight.w600), style: TextStyle(fontWeight: FontWeight.w500),
) )
else else
const Text( const Text(
'Permanently banned', 'Permanently banned',
style: TextStyle(fontWeight: FontWeight.w600), style: TextStyle(fontWeight: FontWeight.w500),
) )
else else
Text( Text(
'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}', '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( Text.rich(
TextSpan( TextSpan(
children: ircMessage.generateSpan( children: ircMessage.generateSpan(
context,
onTapName: onTapName, onTapName: onTapName,
style: defaultTextStyle, style: defaultTextStyle,
assetsStore: chatStore.assetsStore, assetsStore: chatStore.assetsStore,
@ -206,8 +215,9 @@ class ChatMessage extends StatelessWidget {
Text( Text(
ircMessage.tags['system-msg']!, ircMessage.tags['system-msg']!,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
color: defaultTextStyle.color?.withOpacity(0.5)), color: defaultTextStyle.color?.withOpacity(0.5),
),
), ),
if (ircMessage.tags['msg-id'] == 'announcement') if (ircMessage.tags['msg-id'] == 'announcement')
Row( Row(
@ -217,18 +227,19 @@ class ChatMessage extends StatelessWidget {
size: size:
defaultBadgeSize * chatStore.settings.badgeScale, defaultBadgeSize * chatStore.settings.badgeScale,
), ),
const SizedBox(width: 5.0), const SizedBox(width: 4),
const Text( const Text(
'Announcement', 'Announcement',
style: TextStyle(fontWeight: FontWeight.w600), style: TextStyle(fontWeight: FontWeight.w500),
), ),
], ],
), ),
const SizedBox(height: 5.0), const SizedBox(height: 4),
if (ircMessage.message != null) if (ircMessage.message != null)
Text.rich( Text.rich(
TextSpan( TextSpan(
children: ircMessage.generateSpan( children: ircMessage.generateSpan(
context,
onTapName: onTapName, onTapName: onTapName,
style: defaultTextStyle, style: defaultTextStyle,
assetsStore: chatStore.assetsStore, assetsStore: chatStore.assetsStore,
@ -255,8 +266,9 @@ class ChatMessage extends StatelessWidget {
final paddedMessage = Padding( final paddedMessage = Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: chatStore.settings.messageSpacing / 2, vertical: chatStore.settings.messageSpacing / 2,
horizontal: 10.0), horizontal: 12,
),
child: renderMessage, child: renderMessage,
); );
@ -266,10 +278,7 @@ class ChatMessage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
paddedMessage, paddedMessage,
const Divider( const Divider(),
height: 1.0,
thickness: 1.0,
),
], ],
) )
: paddedMessage; : 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/screens/channel/chat/widgets/chat_message.dart';
import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/block_report_modal.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/profile_picture.dart';
import 'package:frosty/widgets/section_header.dart';
class ChatUserModal extends StatefulWidget { class ChatUserModal extends StatefulWidget {
final ChatStore chatStore; final ChatStore chatStore;
@ -35,92 +32,95 @@ class _ChatUserModalState extends State<ChatUserModal> {
? widget.displayName ? widget.displayName
: '${widget.displayName} (${widget.username})'; : '${widget.displayName} (${widget.username})';
return FrostyBottomSheet( return SizedBox(
child: SizedBox( height: MediaQuery.of(context).size.height * 0.5,
height: MediaQuery.of(context).size.height * 0.5, child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ ListTile(
ListTile( contentPadding: const EdgeInsets.all(12),
contentPadding: const EdgeInsets.all(10.0), leading: ProfilePicture(
leading: ProfilePicture( userLogin: widget.username,
userLogin: widget.username, ),
), title: Row(
title: Row( children: [
children: [ Flexible(
Flexible( child: Tooltip(
child: Tooltip( message: name,
message: name, child: Text(
child: Text( name,
name, overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(fontWeight: FontWeight.bold), ),
),
),
],
),
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();
},
icon: const Icon(Icons.reply_rounded),
),
IconButton(
tooltip: 'More',
onPressed: () => showModalBottomSheet(
context: context,
builder: (context) => BlockReportModal(
authStore: widget.chatStore.auth,
name: name,
userLogin: widget.username,
userId: widget.userId,
),
),
icon: Icon(Icons.adaptive.more_rounded),
),
],
),
),
Expanded(
child: Observer(
builder: (context) {
final userMessages = widget.chatStore.messages.reversed
.where((message) => message.user == widget.username)
.toList();
if (userMessages.isEmpty) {
return const AlertMessage(message: 'No recent messages');
}
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaleFactor: widget.chatStore.settings.messageScale,
),
child: DefaultTextStyle(
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],
chatStore: widget.chatStore,
isModal: true,
), ),
), ),
), ),
], );
), },
trailing: widget.chatStore.auth.isLoggedIn
? Button(
padding: const EdgeInsets.symmetric(horizontal: 15.0),
onPressed: () {
widget.chatStore.textController.text =
'@${widget.username} ';
Navigator.pop(context);
widget.chatStore.textFieldFocusNode.requestFocus();
},
child: const Text('Reply'),
)
: null,
onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context,
builder: (context) => BlockReportModal(
authStore: widget.chatStore.auth,
name: name,
userLogin: widget.username,
userId: widget.userId,
),
),
), ),
const SectionHeader( ),
'Recent messages', ],
padding: EdgeInsets.all(10.0),
),
Expanded(
child: Observer(
builder: (context) {
final userMessages = widget.chatStore.messages.reversed
.where((message) => message.user == widget.username)
.toList();
if (userMessages.isEmpty) {
return const AlertMessage(message: 'No recent messages');
}
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaleFactor:
widget.chatStore.settings.messageScale),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
fontSize: widget.chatStore.settings.fontSize),
child: ListView.builder(
reverse: true,
itemCount: userMessages.length,
itemBuilder: (context, index) => ChatMessage(
ircMessage: userMessages[index],
chatStore: widget.chatStore,
isModal: true,
),
),
),
);
},
),
)
],
),
), ),
); );
} }

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 @override
Future<void> didChangeAppLifecycleState( Future<void> didChangeAppLifecycleState(
AppLifecycleState lifecycleState) async { AppLifecycleState lifecycleState,
) async {
if (Platform.isAndroid && if (Platform.isAndroid &&
!await SimplePip.isAutoPipAvailable && !await SimplePip.isAutoPipAvailable &&
lifecycleState == AppLifecycleState.inactive) { lifecycleState == AppLifecycleState.inactive) {

View file

@ -11,6 +11,7 @@ class VideoBar extends StatelessWidget {
final Color? titleTextColor; final Color? titleTextColor;
final Color? subtitleTextColor; final Color? subtitleTextColor;
final FontWeight? subtitleTextWeight; final FontWeight? subtitleTextWeight;
final EdgeInsets padding;
const VideoBar({ const VideoBar({
Key? key, Key? key,
@ -20,6 +21,7 @@ class VideoBar extends StatelessWidget {
this.titleTextColor, this.titleTextColor,
this.subtitleTextColor, this.subtitleTextColor,
this.subtitleTextWeight, this.subtitleTextWeight,
this.padding = const EdgeInsets.all(12),
}) : super(key: key); }) : super(key: key);
@override @override
@ -33,12 +35,14 @@ class VideoBar extends StatelessWidget {
: '${streamInfo.userName} (${streamInfo.userLogin})'; : '${streamInfo.userName} (${streamInfo.userLogin})';
return Padding( return Padding(
padding: const EdgeInsets.all(10), padding: padding,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ProfilePicture(userLogin: streamInfo.userLogin), ProfilePicture(
const SizedBox(width: 10.0), userLogin: streamInfo.userLogin,
radius: 28,
),
const SizedBox(width: 12),
Flexible( Flexible(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -57,7 +61,7 @@ class VideoBar extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: 5.0), const SizedBox(height: 2),
Tooltip( Tooltip(
message: 'Title: ${streamInfo.title.trim()}', message: 'Title: ${streamInfo.title.trim()}',
showDuration: const Duration(seconds: 5), showDuration: const Duration(seconds: 5),
@ -75,14 +79,13 @@ class VideoBar extends StatelessWidget {
), ),
), ),
if (showCategory) ...[ if (showCategory) ...[
const SizedBox(height: 5.0), const SizedBox(height: 2),
InkWell( InkWell(
onTap: tappableCategory && streamInfo.gameName.isNotEmpty onTap: tappableCategory && streamInfo.gameName.isNotEmpty
? () => Navigator.push( ? () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => CategoryStreams( builder: (context) => CategoryStreams(
categoryName: streamInfo.gameName,
categoryId: streamInfo.gameId, 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/chat/stores/chat_store.dart';
import 'package:frosty/screens/channel/video/video_bar.dart'; import 'package:frosty/screens/channel/video/video_bar.dart';
import 'package:frosty/screens/channel/video/video_store.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:frosty/widgets/uptime.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@ -94,7 +93,8 @@ class VideoOverlay extends StatelessWidget {
]); ]);
} else { } else {
SystemChrome.setPreferredOrientations( SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp]); [DeviceOrientation.portraitUp],
);
SystemChrome.setPreferredOrientations([]); SystemChrome.setPreferredOrientations([]);
} }
}, },
@ -157,7 +157,7 @@ class VideoOverlay extends StatelessWidget {
message: videoStore.paused ? 'Play' : 'Pause', message: videoStore.paused ? 'Play' : 'Pause',
preferBelow: false, preferBelow: false,
child: IconButton( child: IconButton(
iconSize: 50.0, iconSize: 56,
icon: Icon( icon: Icon(
videoStore.paused videoStore.paused
? Icons.play_arrow_rounded ? Icons.play_arrow_rounded
@ -175,7 +175,7 @@ class VideoOverlay extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 10.0, bottom: 12.0), padding: const EdgeInsets.all(12),
child: Row( child: Row(
children: [ children: [
Tooltip( Tooltip(
@ -183,15 +183,12 @@ class VideoOverlay extends StatelessWidget {
preferBelow: false, preferBelow: false,
child: Row( child: Row(
children: [ children: [
const Padding( const Icon(
padding: EdgeInsets.all(2.0), Icons.circle,
child: Icon( color: Colors.red,
Icons.circle, size: 10,
color: Colors.red,
size: 10,
),
), ),
const SizedBox(width: 3.0), const SizedBox(width: 4),
Uptime( Uptime(
startTime: streamInfo.startedAt, startTime: streamInfo.startedAt,
style: const TextStyle( style: const TextStyle(
@ -202,27 +199,24 @@ class VideoOverlay extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 12),
Tooltip( Tooltip(
message: 'Viewer count', message: 'Viewer count',
preferBelow: false, preferBelow: false,
child: GestureDetector( child: GestureDetector(
onTap: () => showModalBottomSheet( onTap: () => showModalBottomSheet(
backgroundColor: Colors.transparent,
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => FrostyBottomSheet( builder: (context) => SizedBox(
child: SizedBox( height:
height: MediaQuery.of(context).size.height * MediaQuery.of(context).size.height * 0.8,
0.8, child: GestureDetector(
child: GestureDetector( onTap: FocusScope.of(context).unfocus,
onTap: FocusScope.of(context).unfocus, child: ChattersList(
child: ChattersList( chatDetailsStore:
chatDetailsStore: chatStore.chatDetailsStore,
chatStore.chatDetailsStore, chatStore: chatStore,
chatStore: chatStore, userLogin: streamInfo.userLogin,
userLogin: streamInfo.userLogin,
),
), ),
), ),
), ),
@ -234,10 +228,11 @@ class VideoOverlay extends StatelessWidget {
size: 14, size: 14,
color: Colors.white, color: Colors.white,
), ),
const SizedBox(width: 5), const SizedBox(width: 4),
Text( Text(
NumberFormat().format( NumberFormat().format(
videoStore.streamInfo?.viewerCount), videoStore.streamInfo?.viewerCount,
),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -267,7 +262,7 @@ class VideoOverlay extends StatelessWidget {
if (orientation == Orientation.landscape) fullScreenButton, if (orientation == Orientation.landscape) fullScreenButton,
], ],
), ),
) ),
], ],
); );
}, },

View file

@ -39,18 +39,26 @@ abstract class VideoStoreBase with Store {
WebViewController.fromPlatformCreationParams(_videoWebViewParams) WebViewController.fromPlatformCreationParams(_videoWebViewParams)
..setBackgroundColor(Colors.black) ..setBackgroundColor(Colors.black)
..setJavaScriptMode(JavaScriptMode.unrestricted) ..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('VideoPause', onMessageReceived: (message) { ..addJavaScriptChannel(
_paused = true; 'VideoPause',
if (Platform.isAndroid) pip.setIsPlaying(false); onMessageReceived: (message) {
}) _paused = true;
..addJavaScriptChannel('VideoPlaying', onMessageReceived: (message) { if (Platform.isAndroid) pip.setIsPlaying(false);
_paused = false; },
if (Platform.isAndroid) pip.setIsPlaying(true); )
videoWebViewController.runJavaScript( ..addJavaScriptChannel(
'document.getElementsByTagName("video")[0].muted = false;'); 'VideoPlaying',
videoWebViewController.runJavaScript( onMessageReceived: (message) {
'document.getElementsByTagName("video")[0].volume = 1.0;'); _paused = false;
}) if (Platform.isAndroid) pip.setIsPlaying(true);
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].muted = false;',
);
videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].volume = 1.0;',
);
},
)
..setNavigationDelegate( ..setNavigationDelegate(
NavigationDelegate( NavigationDelegate(
onPageFinished: (_) => initVideo(), 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. // Add event listeners to notify the JavaScript channels when the video plays and pauses.
try { try {
videoWebViewController.runJavaScript( videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));'); 'document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));',
);
videoWebViewController.runJavaScript( videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));'); 'document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));',
);
} catch (e) { } catch (e) {
debugPrint(e.toString()); debugPrint(e.toString());
} }
@ -150,7 +160,7 @@ abstract class VideoStoreBase with Store {
if (Platform.isIOS) { if (Platform.isIOS) {
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
final info = await deviceInfo.iosInfo; final info = await deviceInfo.iosInfo;
if (info.model?.toLowerCase().contains('ipad') == true) { if (info.model.toLowerCase().contains('ipad')) {
_isIPad = true; _isIPad = true;
} else { } else {
_isIPad = false; _isIPad = false;
@ -181,7 +191,9 @@ abstract class VideoStoreBase with Store {
Future<void> updateStreamInfo() async { Future<void> updateStreamInfo() async {
try { try {
_streamInfo = await twitchApi.getStream( _streamInfo = await twitchApi.getStream(
userLogin: userLogin, headers: authStore.headersTwitch); userLogin: userLogin,
headers: authStore.headersTwitch,
);
} catch (e) { } catch (e) {
debugPrint(e.toString()); debugPrint(e.toString());
@ -227,7 +239,8 @@ abstract class VideoStoreBase with Store {
.runJavaScript('document.getElementsByTagName("video")[0].play();'); .runJavaScript('document.getElementsByTagName("video")[0].play();');
} else { } else {
videoWebViewController.runJavaScript( videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].pause();'); 'document.getElementsByTagName("video")[0].pause();',
);
} }
} catch (e) { } catch (e) {
debugPrint(e.toString()); debugPrint(e.toString());
@ -244,7 +257,8 @@ abstract class VideoStoreBase with Store {
pip.enterPipMode(autoEnter: true); pip.enterPipMode(autoEnter: true);
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
videoWebViewController.runJavaScript( videoWebViewController.runJavaScript(
'document.getElementsByTagName("video")[0].requestPictureInPicture();'); 'document.getElementsByTagName("video")[0].requestPictureInPicture();',
);
} }
} catch (e) { } catch (e) {
debugPrint(e.toString()); 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/settings.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class Home extends StatefulWidget { class Home extends StatefulWidget {
@ -27,6 +28,8 @@ class _HomeState extends State<Home> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
SystemChrome.setPreferredOrientations([]); SystemChrome.setPreferredOrientations([]);
final isLoggedIn = _authStore.isLoggedIn && _authStore.user.details != null;
return GestureDetector( return GestureDetector(
onTap: FocusScope.of(context).unfocus, onTap: FocusScope.of(context).unfocus,
child: Scaffold( child: Scaffold(
@ -46,7 +49,12 @@ class _HomeState extends State<Home> {
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Settings', 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( onPressed: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -54,7 +62,7 @@ class _HomeState extends State<Home> {
Settings(settingsStore: context.read<SettingsStore>()), Settings(settingsStore: context.read<SettingsStore>()),
), ),
), ),
) ),
], ],
), ),
body: SafeArea( body: SafeArea(
@ -78,33 +86,29 @@ class _HomeState extends State<Home> {
), ),
), ),
bottomNavigationBar: Observer( bottomNavigationBar: Observer(
builder: (_) => BottomNavigationBar( builder: (_) => NavigationBar(
selectedFontSize: 12.0, destinations: [
type: BottomNavigationBarType.fixed,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),
items: [
if (_authStore.isLoggedIn) if (_authStore.isLoggedIn)
BottomNavigationBarItem( NavigationDestination(
icon: _homeStore.selectedIndex == 0 icon: _homeStore.selectedIndex == 0
? const Icon(Icons.favorite_rounded) ? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_border_rounded), : const Icon(Icons.favorite_border_rounded),
label: 'Following', label: 'Following',
tooltip: 'Followed streams', tooltip: 'Followed streams',
), ),
const BottomNavigationBarItem( const NavigationDestination(
icon: Icon(Icons.arrow_upward_rounded), icon: Icon(Icons.arrow_upward_rounded),
label: 'Top', label: 'Top',
tooltip: 'Top streams and categories', tooltip: 'Top streams and categories',
), ),
const BottomNavigationBarItem( const NavigationDestination(
icon: Icon(Icons.search_rounded), icon: Icon(Icons.search_rounded),
label: 'Search', label: 'Search',
tooltip: 'Search for channels and categories', tooltip: 'Search for channels and categories',
), ),
], ],
currentIndex: _homeStore.selectedIndex, selectedIndex: _homeStore.selectedIndex,
onTap: _homeStore.handleTap, onDestinationSelected: _homeStore.handleTap,
), ),
), ),
), ),

View file

@ -47,7 +47,7 @@ abstract class HomeStoreBase with Store {
if (index != _selectedIndex) { if (index != _selectedIndex) {
_selectedIndex = index; _selectedIndex = index;
} else { } 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. // Use different logic if logged in/out since there will be one less tab when logged out.
if (authStore.isLoggedIn) { 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/home/search/search_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/section_header.dart'; import 'package:frosty/widgets/section_header.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -33,20 +32,17 @@ class _SearchState extends State<Search> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const headerPadding = EdgeInsets.fromLTRB(15.0, 20.0, 15.0, 10.0);
return Column( return Column(
children: [ children: [
Padding( Observer(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), builder: (context) {
child: Observer( return Padding(
builder: (context) { padding: const EdgeInsets.all(12),
return TextField( child: TextField(
controller: _searchStore.textEditingController, controller: _searchStore.textEditingController,
focusNode: _searchStore.textFieldFocusNode, focusNode: _searchStore.textFieldFocusNode,
autocorrect: false, autocorrect: false,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
prefixIcon: const Icon(Icons.search_rounded), prefixIcon: const Icon(Icons.search_rounded),
hintText: 'Find a channel or category', hintText: 'Find a channel or category',
suffixIcon: _searchStore.textFieldFocusNode.hasFocus || suffixIcon: _searchStore.textFieldFocusNode.hasFocus ||
@ -66,84 +62,91 @@ class _SearchState extends State<Search> {
: null, : null,
), ),
onSubmitted: _searchStore.handleQuery, onSubmitted: _searchStore.handleQuery,
); ),
}, );
), },
), ),
Expanded( Expanded(
child: Observer( child: Scrollbar(
builder: (context) { controller: widget.scrollController,
if (_searchStore.textEditingController.text.isEmpty) { child: Observer(
if (_searchStore.searchHistory.isEmpty) { builder: (context) {
return const AlertMessage(message: 'No recent searches'); if (_searchStore.textEditingController.text.isEmpty) {
} if (_searchStore.searchHistory.isEmpty) {
return const AlertMessage(message: 'No recent searches');
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SectionHeader( Padding(
'History', padding: const EdgeInsets.only(left: 16, right: 4),
padding: headerPadding, child: Row(
), mainAxisAlignment: MainAxisAlignment.spaceBetween,
Expanded( children: [
child: ListView( const SectionHeader(
controller: widget.scrollController, 'History',
children: _searchStore.searchHistory padding: EdgeInsets.zero,
.mapIndexed( ),
(index, searchTerm) => FrostyListTile( TextButton(
leading: const Icon(Icons.history_rounded), onPressed: _searchStore.searchHistory.clear,
title: searchTerm, child: const Text('Clear'),
trailing: Tooltip( ),
message: 'Remove', ],
preferBelow: false, ),
child: IconButton( ),
icon: const Icon(Icons.close_rounded), Expanded(
onPressed: () => _searchStore.searchHistory child: ListView(
.removeAt(index), controller: widget.scrollController,
), children: _searchStore.searchHistory
.mapIndexed(
(index, searchTerm) => ListTile(
leading: const Icon(Icons.history_rounded),
title: Text(searchTerm),
onTap: () {
_searchStore.textEditingController.text =
searchTerm;
_searchStore.handleQuery(searchTerm);
_searchStore.textEditingController
.selection = TextSelection.fromPosition(
TextPosition(
offset: _searchStore
.textEditingController.text.length,
),
);
},
), ),
onTap: () { )
_searchStore.textEditingController.text = .toList(),
searchTerm; ),
_searchStore.handleQuery(searchTerm); ),
_searchStore.textEditingController.selection = ],
TextSelection.fromPosition(TextPosition( );
offset: _searchStore }
.textEditingController return CustomScrollView(
.text controller: widget.scrollController,
.length)); slivers: [
}, const SliverToBoxAdapter(
), child: SectionHeader(
) 'Channels',
.toList(), padding: EdgeInsets.fromLTRB(16, 12, 16, 8),
), ),
), ),
SearchResultsChannels(
searchStore: _searchStore,
query: _searchStore.searchText,
),
const SliverToBoxAdapter(
child: SectionHeader(
'Categories',
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
),
SearchResultsCategories(searchStore: _searchStore),
], ],
); );
} },
return CustomScrollView( ),
controller: widget.scrollController,
slivers: [
const SliverToBoxAdapter(
child: SectionHeader(
'Channels',
padding: headerPadding,
),
),
SearchResultsChannels(
searchStore: _searchStore,
query: _searchStore.searchText,
),
const SliverToBoxAdapter(
child: SectionHeader(
'Categories',
padding: headerPadding,
),
),
SearchResultsCategories(searchStore: _searchStore),
],
);
},
), ),
), ),
], ],

View file

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

View file

@ -6,9 +6,7 @@ import 'package:frosty/models/channel.dart';
import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/channel/channel.dart';
import 'package:frosty/screens/home/search/search_store.dart'; import 'package:frosty/screens/home/search/search_store.dart';
import 'package:frosty/widgets/alert_message.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/block_report_modal.dart';
import 'package:frosty/widgets/list_tile.dart';
import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart'; import 'package:frosty/widgets/profile_picture.dart';
import 'package:frosty/widgets/uptime.dart'; import 'package:frosty/widgets/uptime.dart';
@ -46,8 +44,10 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
); );
} catch (error) { } catch (error) {
final snackBar = SnackBar( final snackBar = SnackBar(
content: AlertMessage(message: error.toString()), content: AlertMessage(
behavior: SnackBarBehavior.floating, message: error.toString(),
centered: false,
),
); );
ScaffoldMessenger.of(context).showSnackBar(snackBar); ScaffoldMessenger.of(context).showSnackBar(snackBar);
@ -76,9 +76,10 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
); );
case FutureStatus.fulfilled: case FutureStatus.fulfilled:
final results = (future.result as List<ChannelQuery>).where( final results = (future.result as List<ChannelQuery>).where(
(channel) => !widget.searchStore.authStore.user.blockedUsers (channel) => !widget.searchStore.authStore.user.blockedUsers
.map((blockedUser) => blockedUser.userId) .map((blockedUser) => blockedUser.userId)
.contains(channel.id)); .contains(channel.id),
);
return SliverList( return SliverList(
delegate: SliverChildListDelegate.fixed( delegate: SliverChildListDelegate.fixed(
@ -90,7 +91,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
? channel.displayName ? channel.displayName
: '${channel.displayName} (${channel.broadcasterLogin})'; : '${channel.displayName} (${channel.broadcasterLogin})';
return AnimateScale( return InkWell(
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -105,7 +106,6 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
showModalBottomSheet( showModalBottomSheet(
backgroundColor: Colors.transparent,
context: context, context: context,
builder: (context) => BlockReportModal( builder: (context) => BlockReportModal(
authStore: widget.searchStore.authStore, authStore: widget.searchStore.authStore,
@ -115,11 +115,12 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
), ),
); );
}, },
child: FrostyListTile( child: ListTile(
isThreeLine: false, title: Text(displayName),
title: displayName,
leading: ProfilePicture( leading: ProfilePicture(
userLogin: channel.broadcasterLogin), userLogin: channel.broadcasterLogin,
radius: 16,
),
subtitle: channel.isLive subtitle: channel.isLive
? Row( ? Row(
children: [ children: [
@ -137,11 +138,11 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
); );
}, },
), ),
FrostyListTile( ListTile(
title: 'Go to channel "${widget.query}"', title: Text('Go to channel "${widget.query}"'),
onTap: () => _handleSearch(context, widget.query), onTap: () => _handleSearch(context, widget.query),
trailing: const Icon(Icons.chevron_right_rounded), 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. /// This is used for channels that may not show up in the search results.
Future<Channel> searchChannel(String query) async { Future<Channel> searchChannel(String query) async {
final user = await twitchApi.getUser( final user = await twitchApi.getUser(
userLogin: query, headers: authStore.headersTwitch); userLogin: query,
headers: authStore.headersTwitch,
);
return await twitchApi.getChannel( return await twitchApi.getChannel(
userId: user.id, headers: authStore.headersTwitch); userId: user.id,
headers: authStore.headersTwitch,
);
} }
void dispose() { void dispose() {

View file

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

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/models/stream.dart'; import 'package:frosty/models/stream.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
@ -45,6 +46,9 @@ abstract class ListStoreBase with Store {
@readonly @readonly
var _allStreams = ObservableList<StreamTwitch>(); var _allStreams = ObservableList<StreamTwitch>();
@readonly
CategoryTwitch? _categoryDetails;
/// Whether or not the scroll to top button is visible. /// Whether or not the scroll to top button is visible.
@observable @observable
var showJumpButton = false; var showJumpButton = false;
@ -52,9 +56,11 @@ abstract class ListStoreBase with Store {
/// The list of the fetched streams with blocked users filtered out. /// The list of the fetched streams with blocked users filtered out.
@computed @computed
ObservableList<StreamTwitch> get streams => _allStreams ObservableList<StreamTwitch> get streams => _allStreams
.where((streamInfo) => !authStore.user.blockedUsers .where(
.map((blockedUser) => blockedUser.userId) (streamInfo) => !authStore.user.blockedUsers
.contains(streamInfo.userId)) .map((blockedUser) => blockedUser.userId)
.contains(streamInfo.userId),
)
.toList() .toList()
.asObservable(); .asObservable();
@ -81,6 +87,10 @@ abstract class ListStoreBase with Store {
} }
getStreams(); getStreams();
if (listType == ListType.category && categoryId != null) {
_getCategoryDetails();
}
} }
/// Fetches the streams based on the type and current cursor. /// Fetches the streams based on the type and current cursor.
@ -138,6 +148,20 @@ abstract class ListStoreBase with Store {
return getStreams(); 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. /// Checks the last time the streams were refreshed and updates them if it has been more than 5 minutes.
void checkLastTimeRefreshedAndUpdate() { void checkLastTimeRefreshedAndUpdate() {
final now = DateTime.now(); 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 = late final _$showJumpButtonAtom =
Atom(name: 'ListStoreBase.showJumpButton', context: context); Atom(name: 'ListStoreBase.showJumpButton', context: context);
@ -101,6 +119,15 @@ mixin _$ListStore on ListStoreBase, Store {
return _$getStreamsAsyncAction.run(() => super.getStreams()); 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 = late final _$ListStoreBaseActionController =
ActionController(name: 'ListStoreBase', context: context); 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/large_stream_card.dart';
import 'package:frosty/screens/home/stream_list/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/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/auth_store.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart';
import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/alert_message.dart';
import 'package:frosty/widgets/loading_indicator.dart'; import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/scroll_to_top_button.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
/// A widget that displays a list of followed or top streams based on the provided [listType]. /// 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. /// The type of list to display.
final ListType listType; final ListType listType;
final String? categoryId;
/// The scroll controller to use for scroll to top functionality. /// The scroll controller to use for scroll to top functionality.
final ScrollController scrollController; final ScrollController? scrollController;
final bool showJumpButton;
const StreamsList({ const StreamsList({
Key? key, Key? key,
required this.listType, required this.listType,
required this.scrollController, this.categoryId,
this.scrollController,
this.showJumpButton = false,
}) : super(key: key); }) : super(key: key);
@override @override
@ -36,6 +44,9 @@ class _StreamsListState extends State<StreamsList>
authStore: context.read<AuthStore>(), authStore: context.read<AuthStore>(),
twitchApi: context.read<TwitchApi>(), twitchApi: context.read<TwitchApi>(),
listType: widget.listType, listType: widget.listType,
categoryId: widget.categoryId,
scrollController: widget.scrollController ??
(widget.showJumpButton ? ScrollController() : null),
); );
@override @override
@ -62,7 +73,7 @@ class _StreamsListState extends State<StreamsList>
_listStore.checkLastTimeRefreshedAndUpdate(); _listStore.checkLastTimeRefreshedAndUpdate();
return RefreshIndicator( return RefreshIndicator.adaptive(
onRefresh: () async { onRefresh: () async {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
@ -70,8 +81,10 @@ class _StreamsListState extends State<StreamsList>
if (_listStore.error != null) { if (_listStore.error != null) {
final snackBar = SnackBar( final snackBar = SnackBar(
content: AlertMessage(message: _listStore.error!), content: AlertMessage(
behavior: SnackBarBehavior.floating, message: _listStore.error!,
centered: false,
),
); );
if (!mounted) return; if (!mounted) return;
@ -92,9 +105,10 @@ class _StreamsListState extends State<StreamsList>
const LoadingIndicator(subtitle: 'Loading streams...'); const LoadingIndicator(subtitle: 'Loading streams...');
} else { } else {
statusWidget = AlertMessage( statusWidget = AlertMessage(
message: widget.listType == ListType.followed message: widget.listType == ListType.followed
? 'No followed streams' ? 'No followed streams'
: 'No top streams'); : 'No top streams',
);
} }
} }
@ -105,39 +119,80 @@ class _StreamsListState extends State<StreamsList>
child: Center( child: Center(
child: statusWidget, child: statusWidget,
), ),
) ),
], ],
); );
} }
return ListView.builder( return Stack(
physics: const AlwaysScrollableScrollPhysics(), alignment: AlignmentDirectional.bottomCenter,
controller: widget.scrollController, children: [
itemCount: _listStore.streams.length, Column(
itemBuilder: (context, index) { children: [
if (index > _listStore.streams.length - 10 && if (widget.categoryId != null &&
_listStore.hasMore) { _listStore.categoryDetails != null)
debugPrint('$index ${_listStore.streams.length}'); CategoryCard(
category: _listStore.categoryDetails!,
isTappable: false,
),
Expanded(
child: Scrollbar(
controller: _listStore.scrollController,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
controller: _listStore.scrollController,
itemCount: _listStore.streams.length,
itemBuilder: (context, index) {
if (index > _listStore.streams.length - 10 &&
_listStore.hasMore) {
debugPrint('$index ${_listStore.streams.length}');
_listStore.getStreams(); _listStore.getStreams();
} }
return Observer( return Observer(
builder: (context) => builder: (context) =>
context.read<SettingsStore>().largeStreamCard context.read<SettingsStore>().largeStreamCard
? LargeStreamCard( ? LargeStreamCard(
key: ValueKey(_listStore.streams[index].userId), key: ValueKey(
streamInfo: _listStore.streams[index], _listStore.streams[index].userId,
showThumbnail: ),
context.read<SettingsStore>().showThumbnails, streamInfo: _listStore.streams[index],
showThumbnail: context
.read<SettingsStore>()
.showThumbnails,
showCategory: widget.categoryId == null,
)
: StreamCard(
key: ValueKey(
_listStore.streams[index].userId,
),
streamInfo: _listStore.streams[index],
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!,
) )
: StreamCard( : null,
key: ValueKey(_listStore.streams[index].userId), ),
streamInfo: _listStore.streams[index], ),
showThumbnail: ],
context.read<SettingsStore>().showThumbnails,
),
);
},
); );
}, },
), ),

View file

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

View file

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

View file

@ -1,218 +1,29 @@
import 'package:flutter/material.dart'; 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/home/stream_list/stream_list_store.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/home/stream_list/streams_list.dart';
import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/app_bar.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';
/// A widget that displays a list of streams under the provided [categoryId]. /// 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 StatelessWidget {
class CategoryStreams extends StatefulWidget {
/// The category name, used for the header on the box art sliver.
final String categoryName;
/// The category id, used for fetching the relevant streams in the [ListStore]. /// The category id, used for fetching the relevant streams in the [ListStore].
final String categoryId; final String categoryId;
const CategoryStreams({ const CategoryStreams({
Key? key, Key? key,
required this.categoryName,
required this.categoryId, required this.categoryId,
}) : super(key: key); }) : 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 @override
Widget build(BuildContext context) { 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( return Scaffold(
body: RefreshIndicator( appBar: const FrostyAppBar(
onRefresh: () async { title: SizedBox(),
HapticFeedback.lightImpact(); ),
await _listStore.refreshStreams(); body: StreamsList(
listType: ListType.category,
if (_listStore.error != null) { categoryId: categoryId,
final snackBar = SnackBar( showJumpButton: true,
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,
),
),
),
],
);
},
),
), ),
); );
} }
@override
void dispose() {
_listStore.dispose();
super.dispose();
}
} }

View file

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

View file

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

View file

@ -20,7 +20,7 @@ class OnboardingSetup extends StatelessWidget {
return OnboardingScaffold( return OnboardingScaffold(
header: 'Setup', header: 'Setup',
subtitle: 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( content: Observer(
builder: (context) { builder: (context) {
return ListView( return ListView(
@ -35,7 +35,8 @@ class OnboardingSetup extends StatelessWidget {
SettingsListSwitch( SettingsListSwitch(
title: 'Share crash logs and analytics', title: 'Share crash logs and analytics',
subtitle: const Text( 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, value: settingsStore.shareCrashLogsAndAnalytics,
onChanged: (newValue) { onChanged: (newValue) {
settingsStore.shareCrashLogsAndAnalytics = newValue; settingsStore.shareCrashLogsAndAnalytics = newValue;

View file

@ -11,7 +11,7 @@ class OnboardingWelcome extends StatelessWidget {
const text = [ 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.', '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".', '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( return OnboardingScaffold(
@ -20,21 +20,23 @@ class OnboardingWelcome extends StatelessWidget {
content: Opacity( content: Opacity(
opacity: 0.8, opacity: 0.8,
child: ListView( child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20.0), padding: const EdgeInsets.symmetric(horizontal: 12),
children: text children: text
.mapIndexed((index, sentence) => Padding( .mapIndexed(
padding: const EdgeInsets.symmetric(vertical: 10.0), (index, sentence) => Padding(
child: ListTile( padding: const EdgeInsets.symmetric(vertical: 8),
leading: Text( child: ListTile(
'${index + 1}.', leading: Text(
style: const TextStyle( '${index + 1}.',
fontSize: 16, style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 16,
), fontWeight: FontWeight.w600,
), ),
title: Text(sentence),
), ),
)) title: Text(sentence),
),
),
)
.toList(), .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/account/blocked_users.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/screens/settings/widgets/settings_tile_route.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 { class AccountOptions extends StatelessWidget {
final AuthStore authStore; final AuthStore authStore;
@ -14,11 +11,15 @@ class AccountOptions extends StatelessWidget {
Future<void> _showLogoutDialog(BuildContext context) { Future<void> _showLogoutDialog(BuildContext context) {
return showDialog( return showDialog(
context: context, context: context,
builder: (context) => FrostyDialog( builder: (context) => AlertDialog.adaptive(
title: 'Log Out', title: const Text('Log out'),
message: 'Are you sure you want to log out?', content: const Text('Are you sure you want to log out?'),
actions: [ actions: [
Button( TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: () { onPressed: () {
authStore.logout(); authStore.logout();
Navigator.pop(context); Navigator.pop(context);
@ -26,11 +27,6 @@ class AccountOptions extends StatelessWidget {
}, },
child: const Text('Yes'), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return ListView(
shrinkWrap: true,
primary: false,
children: [ children: [
SettingsTileRoute( SettingsTileRoute(
leading: const Icon(Icons.block_rounded), leading: const Icon(Icons.block_rounded),
title: 'Blocked', title: 'Blocked users',
child: BlockedUsers( child: BlockedUsers(
authStore: authStore, authStore: authStore,
), ),
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.logout_rounded), leading: const Icon(Icons.logout_rounded),
title: 'Log out', title: const Text('Log out'),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _showLogoutDialog(context), onTap: () => _showLogoutDialog(context),
) ),
], ],
); );
} }

View file

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

View file

@ -1,14 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/settings/account/account_options.dart'; import 'package:frosty/screens/settings/account/account_options.dart';
import 'package:frosty/screens/settings/stores/auth_store.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/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:frosty/widgets/profile_picture.dart';
import 'package:simple_icons/simple_icons.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
class ProfileCard extends StatelessWidget { class ProfileCard extends StatelessWidget {
@ -16,63 +12,12 @@ class ProfileCard extends StatelessWidget {
const ProfileCard({Key? key, required this.authStore}) : super(key: key); const ProfileCard({Key? key, required this.authStore}) : super(key: key);
Future<void> _showLoginDialog(BuildContext context) { Future<void> _showAccountOptionsModalBottomSheet(BuildContext context) {
return showDialog( return showModalBottomSheet(
context: context, context: context,
builder: (context) => FrostyDialog( builder: (context) {
title: 'Log In', return AccountOptions(authStore: authStore);
content: Column( },
children: [
Button(
padding:
const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: const FrostyAppBar(
title: Text('Connect with Twitch'),
),
body: WebViewWidget(
controller: authStore.createAuthWebViewController(),
),
);
},
),
),
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);
},
)
],
),
),
); );
} }
@ -81,39 +26,62 @@ class ProfileCard extends StatelessWidget {
return Observer( return Observer(
builder: (context) { builder: (context) {
if (authStore.error != null) { if (authStore.error != null) {
return FrostyListTile( return ListTile(
leading: const Icon( leading: const Icon(
Icons.error_outline_rounded, Icons.error_outline_rounded,
color: Colors.red, color: Colors.red,
), ),
title: 'Failed to connect', title: const Text('Failed to connect'),
trailing: Button( trailing: FilledButton.tonal(
onPressed: authStore.init, onPressed: authStore.init,
padding: const EdgeInsets.symmetric(horizontal: 15.0),
child: const Text('Reconnect'), child: const Text('Reconnect'),
), ),
); );
} }
if (authStore.isLoggedIn && authStore.user.details != null) { if (authStore.isLoggedIn && authStore.user.details != null) {
return SettingsTileRoute( return ListTile(
leading: ProfilePicture( leading: ProfilePicture(
userLogin: authStore.user.details!.login, userLogin: authStore.user.details!.login,
radius: 12, radius: 12,
), ),
title: authStore.user.details!.displayName, title: Text(authStore.user.details!.displayName),
child: AccountOptions(authStore: authStore), trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => _showAccountOptionsModalBottomSheet(context),
); );
} }
return FrostyListTile( return ListTile(
leading: const Icon(Icons.no_accounts_rounded), leading: const Icon(Icons.no_accounts_rounded),
title: 'Anonymous', title: const Text('Anonymous'),
subtitle: const Text( subtitle: const Text(
'Tap to log in and enable the ability to chat, view followed streams, and more.'), 'Log in to enable the ability to chat, view followed streams, and more.',
),
trailing: const SizedBox( trailing: const SizedBox(
height: double.infinity, height: double.infinity,
child: Icon(Icons.chevron_right_rounded), child: Icon(Icons.chevron_right_rounded),
), ),
onTap: () => _showLoginDialog(context), onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: const FrostyAppBar(
title: Text('Connect with Twitch'),
),
body: WebViewWidget(
controller: authStore.createAuthWebViewController(),
),
);
},
),
),
onLongPress: () async {
final clipboardText =
(await Clipboard.getData(Clipboard.kTextPlain))?.text;
if (clipboardText == null) return;
authStore.login(token: clipboardText);
},
); );
}, },
); );

View file

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

View file

@ -24,7 +24,7 @@ class GeneralSettings extends StatelessWidget {
onChanged: (newTheme) => settingsStore.themeType = onChanged: (newTheme) => settingsStore.themeType =
ThemeType.values[themeNames.indexOf(newTheme)], ThemeType.values[themeNames.indexOf(newTheme)],
), ),
const SectionHeader('Stream card'), const SectionHeader('Stream card', showDivider: true),
SettingsListSwitch( SettingsListSwitch(
title: 'Use large stream card', title: 'Use large stream card',
value: settingsStore.largeStreamCard, value: settingsStore.largeStreamCard,
@ -35,7 +35,7 @@ class GeneralSettings extends StatelessWidget {
value: settingsStore.showThumbnails, value: settingsStore.showThumbnails,
onChanged: (newValue) => settingsStore.showThumbnails = newValue, onChanged: (newValue) => settingsStore.showThumbnails = newValue,
), ),
const SectionHeader('Links'), const SectionHeader('Links', showDivider: true),
SettingsListSwitch( SettingsListSwitch(
title: 'Open links in external browser', title: 'Open links in external browser',
value: settingsStore.launchUrlExternal, 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/stores/settings_store.dart';
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart'; import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
import 'package:frosty/widgets/alert_message.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:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -31,11 +28,15 @@ class _OtherSettingsState extends State<OtherSettings> {
Future<void> _showConfirmDialog(BuildContext context) { Future<void> _showConfirmDialog(BuildContext context) {
return showDialog( return showDialog(
context: context, context: context,
builder: (context) => FrostyDialog( builder: (context) => AlertDialog.adaptive(
title: 'Reset All Settings', title: const Text('Reset all settings'),
message: 'Are you sure you want to reset all settings?', content: const Text('Are you sure you want to reset all settings?'),
actions: [ actions: [
Button( TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
@ -45,18 +46,15 @@ class _OtherSettingsState extends State<OtherSettings> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: AlertMessage(message: 'All settings reset'), content: AlertMessage(
behavior: SnackBarBehavior.floating, message: 'All settings reset',
centered: false,
),
), ),
); );
}, },
child: const Text('Yes'), 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) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
FrostyListTile( ListTile(
leading: const Icon(Icons.info_outline_rounded), leading: const Icon(Icons.info_outline_rounded),
title: 'About Frosty', title: const Text('About Frosty'),
onTap: () async { onTap: () async {
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
@ -87,26 +85,29 @@ class _OtherSettingsState extends State<OtherSettings> {
); );
}, },
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.launch_rounded), leading: const Icon(Icons.launch_rounded),
title: 'Changelog', title: const Text('Changelog'),
onTap: () => launchUrl( onTap: () => launchUrl(
Uri.parse('https://github.com/tommyxchow/frosty/releases'), Uri.parse('https://github.com/tommyxchow/frosty/releases'),
mode: widget.settingsStore.launchUrlExternal mode: widget.settingsStore.launchUrlExternal
? LaunchMode.externalApplication ? LaunchMode.externalApplication
: LaunchMode.inAppWebView), : LaunchMode.inAppWebView,
),
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.launch_rounded), leading: const Icon(Icons.launch_rounded),
title: 'FAQ', title: const Text('FAQ'),
onTap: () => launchUrl(Uri.parse('https://www.frostyapp.io/#faq'), onTap: () => launchUrl(
mode: widget.settingsStore.launchUrlExternal Uri.parse('https://www.frostyapp.io/#faq'),
? LaunchMode.externalApplication mode: widget.settingsStore.launchUrlExternal
: LaunchMode.inAppWebView), ? LaunchMode.externalApplication
: LaunchMode.inAppWebView,
),
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.delete_outline_rounded), leading: const Icon(Icons.delete_outline_rounded),
title: 'Clear image cache', title: const Text('Clear image cache'),
onTap: () async { onTap: () async {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
@ -115,22 +116,25 @@ class _OtherSettingsState extends State<OtherSettings> {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: AlertMessage(message: 'Image cache cleared'), content: AlertMessage(
behavior: SnackBarBehavior.floating, message: 'Image cache cleared',
centered: false,
),
), ),
); );
}, },
), ),
FrostyListTile( ListTile(
leading: const Icon(Icons.restore_rounded), leading: const Icon(Icons.restore_rounded),
title: 'Reset settings', title: const Text('Reset settings'),
onTap: () => _showConfirmDialog(context), onTap: () => _showConfirmDialog(context),
), ),
Observer( Observer(
builder: (_) => SettingsListSwitch( builder: (_) => SettingsListSwitch(
title: 'Share crash logs and analytics', title: 'Share crash logs and analytics',
subtitle: const Text( 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, value: widget.settingsStore.shareCrashLogsAndAnalytics,
onChanged: (newValue) { onChanged: (newValue) {
widget.settingsStore.shareCrashLogsAndAnalytics = newValue; widget.settingsStore.shareCrashLogsAndAnalytics = newValue;

View file

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

View file

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

View file

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

View file

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

View file

@ -22,18 +22,20 @@ class VideoSettings extends StatelessWidget {
value: settingsStore.showVideo, value: settingsStore.showVideo,
onChanged: (newValue) => settingsStore.showVideo = newValue, onChanged: (newValue) => settingsStore.showVideo = newValue,
), ),
const SectionHeader('Overlay'), const SectionHeader('Overlay', showDivider: true),
SettingsListSwitch( SettingsListSwitch(
title: 'Use custom video overlay', title: 'Use custom video overlay',
subtitle: const Text( 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, value: settingsStore.showOverlay,
onChanged: (newValue) => settingsStore.showOverlay = newValue, onChanged: (newValue) => settingsStore.showOverlay = newValue,
), ),
SettingsListSwitch( SettingsListSwitch(
title: 'Long-press player to toggle overlay', title: 'Long-press player to toggle overlay',
subtitle: const Text( 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, value: settingsStore.toggleableOverlay,
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue, onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,
), ),

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:frosty/widgets/bottom_sheet.dart';
/// A custom-styled adaptive [ListTile] with options to select. /// A custom-styled adaptive [ListTile] with options to select.
class SettingsListSelect extends StatelessWidget { class SettingsListSelect extends StatelessWidget {
@ -21,53 +20,24 @@ class SettingsListSelect extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
isThreeLine: subtitle != null, title: Text(title),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Padding(
subtitle: subtitle != null ? Text(subtitle!) : null, padding: const EdgeInsets.symmetric(vertical: 8),
trailing: SizedBox( child: SegmentedButton(
height: double.infinity, style: const ButtonStyle(visualDensity: VisualDensity.compact),
child: Row( segments: options
mainAxisSize: MainAxisSize.min, .map(
children: [ (option) => ButtonSegment(
Opacity( value: option,
opacity: 0.8, label: Text(
child: Text( option,
selectedOption, 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 .toList(),
.map((option) => ListTile( selected: {selectedOption},
title: Text(option), onSelectionChanged: (selection) => onChanged(selection.first),
trailing: selectedOption == option
? const Icon(Icons.check_rounded)
: null,
onTap: () {
onChanged(option);
Navigator.of(context).pop();
}))
.toList()
],
),
), ),
), ),
); );

View file

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

View file

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

View file

@ -17,7 +17,7 @@ class SettingsTileRoute extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
leading: leading, leading: leading,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), title: Text(title),
trailing: const Icon(Icons.chevron_right_rounded), trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( 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 { class AlertMessage extends StatelessWidget {
final String message; final String message;
final Color? color; final Color? color;
final bool centered;
const AlertMessage({ const AlertMessage({
Key? key, Key? key,
required this.message, required this.message,
this.centered = true,
this.color, this.color,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment:
centered ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [ children: [
Icon( Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: color ?? Colors.grey, color: color ?? Colors.grey,
), ),
const SizedBox(width: 5.0), const SizedBox(width: 8),
Flexible( Flexible(
child: Text( child: Text(
message, 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:flutter/material.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/app_bar.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'; import 'package:webview_flutter/webview_flutter.dart';
class BlockReportModal extends StatelessWidget { class BlockReportModal extends StatelessWidget {
@ -21,44 +19,46 @@ class BlockReportModal extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FrostyBottomSheet( return ListView(
child: Column( primary: false,
mainAxisSize: MainAxisSize.min, shrinkWrap: true,
children: [ children: [
if (authStore.isLoggedIn) if (authStore.isLoggedIn)
FrostyListTile( ListTile(
leading: const Icon(Icons.block_rounded), leading: const Icon(Icons.block_rounded),
onTap: () => authStore onTap: () => authStore
.showBlockDialog(context, .showBlockDialog(
targetUser: name, targetUserId: userId) context,
.then((_) => Navigator.pop(context)), targetUser: name,
title: 'Block $name', targetUserId: userId,
)
.then((_) => Navigator.pop(context)),
title: Text('Block $name'),
),
ListTile(
leading: const Icon(Icons.outlined_flag_rounded),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: FrostyAppBar(
title: Text('Report $name'),
),
body: WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(
Uri.parse('https://www.twitch.tv/$userLogin/report'),
),
),
);
},
), ),
FrostyListTile( ),
leading: const Icon(Icons.outlined_flag_rounded), title: Text('Report $name'),
onTap: () => Navigator.push( ),
context, ],
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: FrostyAppBar(
title: Text('Report $name'),
),
body: WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..loadRequest(
Uri.parse('https://www.twitch.tv/$userLogin/report'),
),
),
);
},
),
),
title: '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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog.adaptive(
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),
),
title: Text( title: Text(
title, title,
style: const TextStyle(fontWeight: FontWeight.bold), // style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center, // textAlign: TextAlign.center,
), ),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column(children: [ child: Column(
content ?? Text(message!, textAlign: TextAlign.center), children: [
if (actions != null) ...[ content ?? Text(message!, textAlign: TextAlign.center),
const SizedBox(height: 25.0), if (actions != null) ...[
...?actions?.map( const SizedBox(height: 16),
(action) => Container( ...?actions?.map(
width: double.infinity, (action) => Container(
padding: const EdgeInsets.only(top: 5.0), width: double.infinity,
child: action, 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 String? subtitle;
final double spacing; 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); : super(key: key);
@override @override

View file

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

View file

@ -1,6 +1,4 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:frosty/widgets/button.dart';
class FrostyPageView extends StatefulWidget { class FrostyPageView extends StatefulWidget {
final List<String> headers; final List<String> headers;
@ -18,63 +16,34 @@ class FrostyPageView extends StatefulWidget {
} }
class _FrostyPageViewState extends State<FrostyPageView> { class _FrostyPageViewState extends State<FrostyPageView> {
var currentIndex = 0;
late final PageController _pageContoller =
PageController(initialPage: currentIndex);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return DefaultTabController(
crossAxisAlignment: CrossAxisAlignment.start, length: widget.headers.length,
children: [ child: Column(
const Divider( crossAxisAlignment: CrossAxisAlignment.start,
height: 5.0, children: [
thickness: 1.0, TabBar(
), isScrollable: true,
SingleChildScrollView( labelStyle: const TextStyle(
scrollDirection: Axis.horizontal, fontSize: 14,
child: Row( fontWeight: FontWeight.w600,
children: [ ),
...widget.headers.mapIndexed( labelPadding: const EdgeInsets.symmetric(
(index, section) => SizedBox( horizontal: 12,
height: 40, ),
child: Button( tabs: widget.headers
padding: const EdgeInsets.symmetric( .map(
horizontal: 10.0, vertical: 5.0), (header) => Tab(
onPressed: () => _pageContoller.animateToPage( text: header,
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),
),
), ),
), )
), .toList(),
],
), ),
), const Divider(),
Expanded( Expanded(child: TabBarView(children: widget.children)),
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:flutter/material.dart';
import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_api.dart';
import 'package:frosty/main.dart';
import 'package:frosty/models/user.dart'; import 'package:frosty/models/user.dart';
import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart';
import 'package:frosty/widgets/cached_image.dart'; import 'package:frosty/widgets/cached_image.dart';
@ -23,27 +22,23 @@ class ProfilePicture extends StatelessWidget {
return ClipOval( return ClipOval(
child: FutureBuilder( child: FutureBuilder(
future: context.read<TwitchApi>().getUser( future: context.read<TwitchApi>().getUser(
userLogin: userLogin, userLogin: userLogin,
headers: context.read<AuthStore>().headersTwitch), headers: context.read<AuthStore>().headersTwitch,
),
builder: (context, AsyncSnapshot<UserTwitch> snapshot) { builder: (context, AsyncSnapshot<UserTwitch> snapshot) {
return AnimatedSwitcher( return snapshot.hasData
duration: const Duration(milliseconds: 200), ? FrostyCachedNetworkImage(
switchInCurve: Curves.easeOut, width: diameter,
switchOutCurve: Curves.easeIn, height: diameter,
child: snapshot.hasData imageUrl: snapshot.data!.profileImageUrl,
? FrostyCachedNetworkImage( placeholder: (context, url) =>
width: diameter, ColoredBox(color: Colors.grey.shade900),
height: diameter, )
imageUrl: snapshot.data!.profileImageUrl, : Container(
placeholder: (context, url) => color: Colors.grey.shade900,
const ColoredBox(color: lightGray), width: diameter,
) height: diameter,
: Container( );
color: lightGray,
width: diameter,
height: diameter,
),
);
}, },
), ),
); );

View file

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

View file

@ -1,30 +1,37 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:frosty/main.dart';
class SectionHeader extends StatelessWidget { class SectionHeader extends StatelessWidget {
final String text; final String text;
final EdgeInsets? padding; final EdgeInsets? padding;
final double? fontSize; final double? fontSize;
final bool showDivider;
const SectionHeader( const SectionHeader(
this.text, { this.text, {
Key? key, Key? key,
this.padding, this.padding,
this.fontSize, this.fontSize,
this.showDivider = false,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Column(
padding: padding ?? const EdgeInsets.fromLTRB(16, 30, 16, 5), mainAxisSize: MainAxisSize.min,
child: Text( crossAxisAlignment: CrossAxisAlignment.start,
text, children: [
style: TextStyle( if (showDivider) const Divider(),
color: purple, Padding(
fontSize: fontSize ?? 16, padding: padding ?? const EdgeInsets.fromLTRB(16, 16, 16, 4),
fontWeight: FontWeight.w600, child: Text(
text,
style: TextStyle(
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 # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # 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. # The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43 # 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 version: 3.1.4+40
environment: 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. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
@ -31,13 +31,12 @@ dependencies:
sdk: flutter sdk: flutter
json_annotation: ^4.6.0 json_annotation: ^4.6.0
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
http: ^0.13.3 http: ^1.1.0
web_socket_channel: ^2.1.0 web_socket_channel: ^2.1.0
flutter_secure_storage: ^8.0.0 flutter_secure_storage: ^9.0.0
intl: ^0.18.1 intl: ^0.18.1
cached_network_image: ^3.1.0 cached_network_image: ^3.1.0
webview_flutter: ^4.2.0 webview_flutter: ^4.2.0
@ -47,11 +46,11 @@ dependencies:
shared_preferences: ^2.0.8 shared_preferences: ^2.0.8
flutter_svg: ^2.0.5 flutter_svg: ^2.0.5
url_launcher: ^6.0.17 url_launcher: ^6.0.17
package_info_plus: ^3.0.2 package_info_plus: ^4.1.0
collection: ^1.16.0 collection: ^1.16.0
flutter_cache_manager: ^3.3.0 flutter_cache_manager: ^3.3.0
device_info_plus: ^8.0.0 device_info_plus: ^9.0.3
wakelock: ^0.6.2 wakelock_plus: ^1.1.2
simple_icons: ^7.10.0 simple_icons: ^7.10.0
simple_pip_mode: simple_pip_mode:
git: git:
@ -85,7 +84,6 @@ dev_dependencies:
# The following section is specific to Flutter packages. # The following section is specific to Flutter packages.
flutter: flutter:
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.
@ -111,6 +109,7 @@ flutter:
assets: assets:
- assets/icons/logo.svg - assets/icons/logo.svg
- assets/fonts/
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware # https://flutter.dev/assets-and-images/#resolution-aware
@ -139,7 +138,6 @@ flutter:
# see https://flutter.dev/custom-fonts/#from-packages # see https://flutter.dev/custom-fonts/#from-packages
flutter_native_splash: flutter_native_splash:
# This package generates native code to customize Flutter's default white native splash screen # This package generates native code to customize Flutter's default white native splash screen
# with background color and splash image. # with background color and splash image.
# Customize the parameters below, and run the following command in the terminal: # 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-Debug.plist'
# - 'ios/Runner/Info-Release.plist' # - 'ios/Runner/Info-Release.plist'
flutter_icons: flutter_launcher_icons:
image_path_android: "assets/icons/icon_android.png" image_path_android: "assets/icons/icon_android.png"
image_path_ios: "assets/icons/icon_ios.png" image_path_ios: "assets/icons/icon_ios.png"
android: true android: true

View file

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

View file

@ -39,7 +39,9 @@ void main() {
expect(stream.title, 'A shorter title...'); expect(stream.title, 'A shorter title...');
expect(stream.viewerCount, 50051); expect(stream.viewerCount, 50051);
expect(stream.startedAt, '2021-09-23T20:06:22Z'); expect(stream.startedAt, '2021-09-23T20:06:22Z');
expect(stream.thumbnailUrl, expect(
'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg'); 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.id, '88888888');
expect(user.login, 'bob'); expect(user.login, 'bob');
expect(user.displayName, 'Bob'); expect(user.displayName, 'Bob');
expect(user.profileImageUrl, expect(
'https://static-cdn.jtvnw.net/user-default-pictures-uv/ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png'); user.profileImageUrl,
'https://static-cdn.jtvnw.net/user-default-pictures-uv/ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png',
);
}); });
} }