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:
parent
06d71a593b
commit
9a6892746d
93 changed files with 2331 additions and 2372 deletions
|
|
@ -8,6 +8,9 @@
|
|||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
analyzer:
|
||||
exclude:
|
||||
- lib/**.g.dart
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
|
|
@ -31,6 +34,6 @@ linter:
|
|||
- avoid_redundant_argument_values
|
||||
- unnecessary_parenthesis
|
||||
- always_declare_return_types
|
||||
|
||||
- require_trailing_commas
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
|
|
|||
19
android/app/src/main/res/values-night-v31/styles.xml
Normal file
19
android/app/src/main/res/values-night-v31/styles.xml
Normal 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>
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
19
android/app/src/main/res/values-v31/styles.xml
Normal file
19
android/app/src/main/res/values-v31/styles.xml
Normal 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.
156
ios/Podfile.lock
156
ios/Podfile.lock
|
|
@ -3,38 +3,38 @@ PODS:
|
|||
- Flutter
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Firebase/Analytics (10.10.0):
|
||||
- Firebase/Analytics (10.15.0):
|
||||
- Firebase/Core
|
||||
- Firebase/Core (10.10.0):
|
||||
- Firebase/Core (10.15.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseAnalytics (~> 10.10.0)
|
||||
- Firebase/CoreOnly (10.10.0):
|
||||
- FirebaseCore (= 10.10.0)
|
||||
- Firebase/Crashlytics (10.10.0):
|
||||
- FirebaseAnalytics (~> 10.15.0)
|
||||
- Firebase/CoreOnly (10.15.0):
|
||||
- FirebaseCore (= 10.15.0)
|
||||
- Firebase/Crashlytics (10.15.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseCrashlytics (~> 10.10.0)
|
||||
- Firebase/Performance (10.10.0):
|
||||
- FirebaseCrashlytics (~> 10.15.0)
|
||||
- Firebase/Performance (10.15.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebasePerformance (~> 10.10.0)
|
||||
- firebase_analytics (10.4.3):
|
||||
- Firebase/Analytics (= 10.10.0)
|
||||
- FirebasePerformance (~> 10.15.0)
|
||||
- firebase_analytics (10.6.0):
|
||||
- Firebase/Analytics (= 10.15.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- firebase_core (2.14.0):
|
||||
- Firebase/CoreOnly (= 10.10.0)
|
||||
- firebase_core (2.18.0):
|
||||
- Firebase/CoreOnly (= 10.15.0)
|
||||
- Flutter
|
||||
- firebase_crashlytics (3.3.3):
|
||||
- Firebase/Crashlytics (= 10.10.0)
|
||||
- firebase_crashlytics (3.4.0):
|
||||
- Firebase/Crashlytics (= 10.15.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- firebase_performance (0.9.2-3):
|
||||
- Firebase/Performance (= 10.10.0)
|
||||
- firebase_performance (0.9.3):
|
||||
- Firebase/Performance (= 10.15.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- FirebaseABTesting (10.11.0):
|
||||
- FirebaseABTesting (10.16.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseAnalytics (10.10.0):
|
||||
- FirebaseAnalytics/AdIdSupport (= 10.10.0)
|
||||
- FirebaseAnalytics (10.15.0):
|
||||
- FirebaseAnalytics/AdIdSupport (= 10.15.0)
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
|
||||
|
|
@ -42,24 +42,24 @@ PODS:
|
|||
- GoogleUtilities/Network (~> 7.11)
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.11)"
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- FirebaseAnalytics/AdIdSupport (10.10.0):
|
||||
- FirebaseAnalytics/AdIdSupport (10.15.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
- GoogleAppMeasurement (= 10.10.0)
|
||||
- GoogleAppMeasurement (= 10.15.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
|
||||
- GoogleUtilities/MethodSwizzler (~> 7.11)
|
||||
- GoogleUtilities/Network (~> 7.11)
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.11)"
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- FirebaseCore (10.10.0):
|
||||
- FirebaseCore (10.15.0):
|
||||
- FirebaseCoreInternal (~> 10.0)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- GoogleUtilities/Logger (~> 7.8)
|
||||
- FirebaseCoreExtension (10.11.0):
|
||||
- FirebaseCoreExtension (10.16.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseCoreInternal (10.11.0):
|
||||
- FirebaseCoreInternal (10.16.0):
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.8)"
|
||||
- FirebaseCrashlytics (10.10.0):
|
||||
- FirebaseCrashlytics (10.15.0):
|
||||
- FirebaseCore (~> 10.5)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
- FirebaseSessions (~> 10.5)
|
||||
|
|
@ -67,12 +67,12 @@ PODS:
|
|||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- PromisesObjC (~> 2.1)
|
||||
- FirebaseInstallations (10.11.0):
|
||||
- FirebaseInstallations (10.16.0):
|
||||
- FirebaseCore (~> 10.0)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- GoogleUtilities/UserDefaults (~> 7.8)
|
||||
- PromisesObjC (~> 2.1)
|
||||
- FirebasePerformance (10.10.0):
|
||||
- FirebasePerformance (10.15.0):
|
||||
- FirebaseCore (~> 10.5)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
- FirebaseRemoteConfig (~> 10.0)
|
||||
|
|
@ -82,13 +82,13 @@ PODS:
|
|||
- GoogleUtilities/ISASwizzler (~> 7.8)
|
||||
- GoogleUtilities/MethodSwizzler (~> 7.8)
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- FirebaseRemoteConfig (10.11.0):
|
||||
- FirebaseRemoteConfig (10.16.0):
|
||||
- FirebaseABTesting (~> 10.0)
|
||||
- FirebaseCore (~> 10.0)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
- GoogleUtilities/Environment (~> 7.8)
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.8)"
|
||||
- FirebaseSessions (10.11.0):
|
||||
- FirebaseSessions (10.16.0):
|
||||
- FirebaseCore (~> 10.5)
|
||||
- FirebaseCoreExtension (~> 10.0)
|
||||
- FirebaseInstallations (~> 10.0)
|
||||
|
|
@ -104,49 +104,49 @@ PODS:
|
|||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
- FMDB/standard (2.7.5)
|
||||
- GoogleAppMeasurement (10.10.0):
|
||||
- GoogleAppMeasurement/AdIdSupport (= 10.10.0)
|
||||
- GoogleAppMeasurement (10.15.0):
|
||||
- GoogleAppMeasurement/AdIdSupport (= 10.15.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
|
||||
- GoogleUtilities/MethodSwizzler (~> 7.11)
|
||||
- GoogleUtilities/Network (~> 7.11)
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.11)"
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- GoogleAppMeasurement/AdIdSupport (10.10.0):
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.10.0)
|
||||
- GoogleAppMeasurement/AdIdSupport (10.15.0):
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.15.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
|
||||
- GoogleUtilities/MethodSwizzler (~> 7.11)
|
||||
- GoogleUtilities/Network (~> 7.11)
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.11)"
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (10.10.0):
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (10.15.0):
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
|
||||
- GoogleUtilities/MethodSwizzler (~> 7.11)
|
||||
- GoogleUtilities/Network (~> 7.11)
|
||||
- "GoogleUtilities/NSData+zlib (~> 7.11)"
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- GoogleDataTransport (9.2.3):
|
||||
- GoogleDataTransport (9.2.5):
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- nanopb (< 2.30910.0, >= 2.30908.0)
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
|
||||
- GoogleUtilities/AppDelegateSwizzler (7.11.5):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Network
|
||||
- GoogleUtilities/Environment (7.11.1):
|
||||
- GoogleUtilities/Environment (7.11.5):
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/ISASwizzler (7.11.1)
|
||||
- GoogleUtilities/Logger (7.11.1):
|
||||
- GoogleUtilities/ISASwizzler (7.11.5)
|
||||
- GoogleUtilities/Logger (7.11.5):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/MethodSwizzler (7.11.1):
|
||||
- GoogleUtilities/MethodSwizzler (7.11.5):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Network (7.11.1):
|
||||
- GoogleUtilities/Network (7.11.5):
|
||||
- GoogleUtilities/Logger
|
||||
- "GoogleUtilities/NSData+zlib"
|
||||
- GoogleUtilities/Reachability
|
||||
- "GoogleUtilities/NSData+zlib (7.11.1)"
|
||||
- GoogleUtilities/Reachability (7.11.1):
|
||||
- "GoogleUtilities/NSData+zlib (7.11.5)"
|
||||
- GoogleUtilities/Reachability (7.11.5):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/UserDefaults (7.11.1):
|
||||
- GoogleUtilities/UserDefaults (7.11.5):
|
||||
- GoogleUtilities/Logger
|
||||
- in_app_review (0.2.0):
|
||||
- Flutter
|
||||
|
|
@ -160,9 +160,9 @@ PODS:
|
|||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- PromisesObjC (2.2.0)
|
||||
- PromisesSwift (2.2.0):
|
||||
- PromisesObjC (= 2.2.0)
|
||||
- PromisesObjC (2.3.1)
|
||||
- PromisesSwift (2.3.1):
|
||||
- PromisesObjC (= 2.3.1)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
|
@ -171,7 +171,7 @@ PODS:
|
|||
- FMDB (>= 2.7.5)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock (0.0.1):
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
|
|
@ -192,7 +192,7 @@ DEPENDENCIES:
|
|||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock (from `.symlinks/plugins/wakelock/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
|
|
@ -247,48 +247,48 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock:
|
||||
:path: ".symlinks/plugins/wakelock/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
|
||||
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
|
||||
Firebase: facd334e557a979bd03a0b58d90fd56b52b8aba0
|
||||
firebase_analytics: 1a5ad75876257318ba5fdc6bf7aae73b6e98d0cf
|
||||
firebase_core: 85b6664038311940ad60584eaabc73103c61f5de
|
||||
firebase_crashlytics: cd12245f604099a4ef6557fb36490abe309a118a
|
||||
firebase_performance: 1e6496ae6464861c0ed1bcda8c0d9b7440469502
|
||||
FirebaseABTesting: 3f6b711718feb87346dc05f8c964601bc8fb2cf6
|
||||
FirebaseAnalytics: 7bc7de519111dae802f5bc0c9c083918f8b8870d
|
||||
FirebaseCore: d027ff503d37edb78db98429b11f580a24a7df2a
|
||||
FirebaseCoreExtension: cacdad57fdb60e0b86dcbcac058ec78237946759
|
||||
FirebaseCoreInternal: 9e46c82a14a3b3a25be4e1e151ce6d21536b89c0
|
||||
FirebaseCrashlytics: bc8f3bed7f4d04be79d87391455efb4c9b163131
|
||||
FirebaseInstallations: 2a2c6859354cbec0a228a863d4daf6de7c74ced4
|
||||
FirebasePerformance: 7ae687b2d6e9656ecd62847053d61adcc96ba544
|
||||
FirebaseRemoteConfig: 5f907cd5cf6f64d026c8bfd196a1086cbe1e95e2
|
||||
FirebaseSessions: a62ba5c45284adb7714f4126cfbdb32b17c260bd
|
||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||
Firebase: 66043bd4579e5b73811f96829c694c7af8d67435
|
||||
firebase_analytics: 6b016f3d8e86fec024f1a495834ae30d44fe4fe2
|
||||
firebase_core: 49fd2395e3fbbf63f453888c8e9f42f541f4c11c
|
||||
firebase_crashlytics: af0e347e4ab14d45da977c485568b0f0f1e697bb
|
||||
firebase_performance: 1ecc6dc781c1cc9be43837ca5b9cdc517b55dfe6
|
||||
FirebaseABTesting: 03f0a8b88cf618350527f2c6a2234e29b9c65064
|
||||
FirebaseAnalytics: 47cef43728f81a839cf1306576bdd77ffa2eac7e
|
||||
FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e
|
||||
FirebaseCoreExtension: 2dbc745b337eb99d2026a7a309ae037bd873f45e
|
||||
FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a
|
||||
FirebaseCrashlytics: a83f26fb922a3fe181eb738fb4dcf0c92bba6455
|
||||
FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee
|
||||
FirebasePerformance: b7988bc06c87e9c3b9e6b1ae854cd460b646ebd6
|
||||
FirebaseRemoteConfig: 17ec974c6cac5cdc6cf8297062c2219851857f06
|
||||
FirebaseSessions: 96e7781e545929cde06dd91088ddbb0841391b43
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
GoogleAppMeasurement: bbbfd4bcb2b40ae9b772c3b0823a58c1e3d618f9
|
||||
GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd
|
||||
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
|
||||
GoogleAppMeasurement: 722db6550d1e6d552b08398b69a975ac61039338
|
||||
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
|
||||
GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084
|
||||
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
|
||||
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
|
||||
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
|
||||
PromisesSwift: cf9eb58666a43bbe007302226e510b16c1e10959
|
||||
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
|
||||
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
|
||||
PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
|
||||
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
|
||||
|
||||
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.13.0
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@
|
|||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1300;
|
||||
LastUpgradeCheck = 1430;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1300"
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
|||
|
|
@ -38,10 +38,14 @@ class BTTVApi {
|
|||
final result = EmoteBTTVChannel.fromJson(decoded);
|
||||
|
||||
final emoteToUrl = <Emote>[];
|
||||
emoteToUrl.addAll(result.channelEmotes
|
||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)));
|
||||
emoteToUrl.addAll(result.sharedEmotes
|
||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)));
|
||||
emoteToUrl.addAll(
|
||||
result.channelEmotes
|
||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvChannel)),
|
||||
);
|
||||
emoteToUrl.addAll(
|
||||
result.sharedEmotes
|
||||
.map((emote) => Emote.fromBTTV(emote, EmoteType.bttvShared)),
|
||||
);
|
||||
|
||||
return emoteToUrl;
|
||||
} else {
|
||||
|
|
@ -62,7 +66,7 @@ class BTTVApi {
|
|||
|
||||
return {
|
||||
for (final badge in badgeObjects)
|
||||
badge.providerId: ChatBadge.fromBTTV(badge)
|
||||
badge.providerId: ChatBadge.fromBTTV(badge),
|
||||
};
|
||||
} else {
|
||||
return Future.error('Failed to get BTTV badges');
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ class TwitchApi {
|
|||
const TwitchApi(this._client);
|
||||
|
||||
/// Returns a list of all Twitch global emotes.
|
||||
Future<List<Emote>> getEmotesGlobal(
|
||||
{required Map<String, String> headers}) async {
|
||||
Future<List<Emote>> getEmotesGlobal({
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse('https://api.twitch.tv/helix/chat/emotes/global');
|
||||
final response = await _client.get(url, headers: headers);
|
||||
|
||||
|
|
@ -72,7 +73,8 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId');
|
||||
'https://api.twitch.tv/helix/chat/emotes/set?emote_set_id=$setId',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
|
|
@ -97,8 +99,9 @@ class TwitchApi {
|
|||
}
|
||||
|
||||
/// Returns a map of global Twitch badges to their [Emote] object.
|
||||
Future<Map<String, ChatBadge>> getBadgesGlobal(
|
||||
{required Map<String, String> headers}) async {
|
||||
Future<Map<String, ChatBadge>> getBadgesGlobal({
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse('https://api.twitch.tv/helix/chat/badges/global');
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
|
|
@ -204,9 +207,11 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final url = Uri.parse(cursor == null
|
||||
final url = Uri.parse(
|
||||
cursor == null
|
||||
? 'https://api.twitch.tv/helix/streams'
|
||||
: 'https://api.twitch.tv/helix/streams?after=$cursor');
|
||||
: 'https://api.twitch.tv/helix/streams?after=$cursor',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
|
@ -223,9 +228,11 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final url = Uri.parse(cursor == null
|
||||
final url = Uri.parse(
|
||||
cursor == null
|
||||
? 'https://api.twitch.tv/helix/streams/followed?user_id=$id'
|
||||
: 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor');
|
||||
: 'https://api.twitch.tv/helix/streams/followed?user_id=$id&after=$cursor',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
|
@ -233,7 +240,8 @@ class TwitchApi {
|
|||
return StreamsTwitch.fromJson(decoded);
|
||||
} else {
|
||||
return Future.error(
|
||||
'Failed to get followed streams: ${decoded['message']}');
|
||||
'Failed to get followed streams: ${decoded['message']}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -243,9 +251,11 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final url = Uri.parse(cursor == null
|
||||
final url = Uri.parse(
|
||||
cursor == null
|
||||
? 'https://api.twitch.tv/helix/streams?game_id=$gameId'
|
||||
: 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor');
|
||||
: 'https://api.twitch.tv/helix/streams?game_id=$gameId&after=$cursor',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
|
@ -253,7 +263,8 @@ class TwitchApi {
|
|||
return StreamsTwitch.fromJson(decoded);
|
||||
} else {
|
||||
return Future.error(
|
||||
'Failed to get streams under category: ${decoded['message']}');
|
||||
'Failed to get streams under category: ${decoded['message']}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,9 +297,11 @@ class TwitchApi {
|
|||
String? id,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(id != null
|
||||
final url = Uri.parse(
|
||||
id != null
|
||||
? 'https://api.twitch.tv/helix/users?id=$id'
|
||||
: 'https://api.twitch.tv/helix/users?login=$userLogin');
|
||||
: 'https://api.twitch.tv/helix/users?login=$userLogin',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
|
@ -312,7 +325,8 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/channels?broadcaster_id=$userId');
|
||||
'https://api.twitch.tv/helix/channels?broadcaster_id=$userId',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
|
@ -335,7 +349,8 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/search/channels?first=8&query=$query');
|
||||
'https://api.twitch.tv/helix/search/channels?first=8&query=$query',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
|
|
@ -352,9 +367,11 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final url = Uri.parse(cursor == null
|
||||
final url = Uri.parse(
|
||||
cursor == null
|
||||
? 'https://api.twitch.tv/helix/games/top'
|
||||
: 'https://api.twitch.tv/helix/games/top?after=$cursor');
|
||||
: 'https://api.twitch.tv/helix/games/top?after=$cursor',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
final decoded = jsonDecode(response.body);
|
||||
|
|
@ -362,7 +379,8 @@ class TwitchApi {
|
|||
return CategoriesTwitch.fromJson(decoded);
|
||||
} else {
|
||||
return Future.error(
|
||||
'Failed to get top categories: ${decoded['message']}');
|
||||
'Failed to get top categories: ${decoded['message']}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -388,9 +406,11 @@ class TwitchApi {
|
|||
required String query,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final url = Uri.parse(cursor == null
|
||||
final url = Uri.parse(
|
||||
cursor == null
|
||||
? 'https://api.twitch.tv/helix/search/categories?first=8&query=$query'
|
||||
: 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor');
|
||||
: 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
|
|
@ -408,7 +428,8 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId');
|
||||
'https://api.twitch.tv/helix/subscriptions?broadcaster_id=$userId',
|
||||
);
|
||||
|
||||
final response = await _client.get(uri, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
|
|
@ -426,9 +447,11 @@ class TwitchApi {
|
|||
required Map<String, String> headers,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final url = Uri.parse(cursor == null
|
||||
final url = Uri.parse(
|
||||
cursor == null
|
||||
? 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id'
|
||||
: 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor');
|
||||
: 'https://api.twitch.tv/helix/users/blocks?first=100&broadcaster_id=$id&after=$cursor',
|
||||
);
|
||||
|
||||
final response = await _client.get(url, headers: headers);
|
||||
if (response.statusCode == 200) {
|
||||
|
|
@ -447,8 +470,13 @@ class TwitchApi {
|
|||
// With the Twitch API, we can make up to 800 requests per minute.
|
||||
// Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute.
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
result.addAll(await getUserBlockedList(
|
||||
id: id, cursor: cursor, headers: headers));
|
||||
result.addAll(
|
||||
await getUserBlockedList(
|
||||
id: id,
|
||||
cursor: cursor,
|
||||
headers: headers,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -462,10 +490,13 @@ class TwitchApi {
|
|||
}
|
||||
|
||||
// Blocks the user with the given ID and returns true on success or false on failure.
|
||||
Future<bool> blockUser(
|
||||
{required String userId, required Map<String, String> headers}) async {
|
||||
Future<bool> blockUser({
|
||||
required String userId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId');
|
||||
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
|
||||
);
|
||||
|
||||
final response = await _client.put(url, headers: headers);
|
||||
if (response.statusCode == 204) {
|
||||
|
|
@ -476,10 +507,13 @@ class TwitchApi {
|
|||
}
|
||||
|
||||
// Unblocks the user with the given ID and returns true on success or false on failure.
|
||||
Future<bool> unblockUser(
|
||||
{required String userId, required Map<String, String> headers}) async {
|
||||
Future<bool> unblockUser({
|
||||
required String userId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final url = Uri.parse(
|
||||
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId');
|
||||
'https://api.twitch.tv/helix/users/blocks?target_user_id=$userId',
|
||||
);
|
||||
|
||||
final response = await _client.delete(url, headers: headers);
|
||||
if (response.statusCode == 204) {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ class DefaultFirebaseOptions {
|
|||
messagingSenderId: '403282332389',
|
||||
projectId: 'frosty-128de',
|
||||
storageBucket: 'frosty-128de.appspot.com',
|
||||
iosClientId: '403282332389-gst8k8gl71a7vrhf5k69nl8pnsq19rg8.apps.googleusercontent.com',
|
||||
iosClientId:
|
||||
'403282332389-gst8k8gl71a7vrhf5k69nl8pnsq19rg8.apps.googleusercontent.com',
|
||||
iosBundleId: 'com.tommychow.frosty',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
130
lib/main.dart
130
lib/main.dart
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:advanced_in_app_review/advanced_in_app_review.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
|
|
@ -17,6 +16,7 @@ import 'package:frosty/screens/home/home.dart';
|
|||
import 'package:frosty/screens/onboarding/onboarding_intro.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/theme.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
@ -91,124 +91,6 @@ void main() async {
|
|||
// Navigator key for sleep timer. Allows navigation popping without context.
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
const gray = Color.fromRGBO(18, 18, 18, 1.0);
|
||||
const lightGray = Color.fromRGBO(28, 28, 28, 1.0);
|
||||
const purple = Color(0xff9146ff);
|
||||
|
||||
const inputTheme = InputDecorationTheme(
|
||||
filled: true,
|
||||
contentPadding: EdgeInsets.all(10.0),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10.0)),
|
||||
borderSide: BorderSide(style: BorderStyle.none),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10.0)),
|
||||
borderSide: BorderSide(style: BorderStyle.none),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10.0)),
|
||||
borderSide: BorderSide(style: BorderStyle.none),
|
||||
),
|
||||
);
|
||||
|
||||
const tooltipTheme = TooltipThemeData(
|
||||
padding: EdgeInsets.all(10.0),
|
||||
margin: EdgeInsets.symmetric(horizontal: 5.0),
|
||||
decoration: BoxDecoration(
|
||||
color: lightGray,
|
||||
borderRadius: BorderRadius.all(Radius.circular(10.0)),
|
||||
),
|
||||
textStyle: TextStyle(color: Colors.white),
|
||||
);
|
||||
|
||||
const snackBarTheme = SnackBarThemeData(
|
||||
backgroundColor: lightGray,
|
||||
contentTextStyle: TextStyle(color: Colors.white),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10.0)),
|
||||
),
|
||||
);
|
||||
|
||||
final lightTheme = ThemeData(
|
||||
canvasColor: Colors.white,
|
||||
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
|
||||
fontFamily: 'Inter',
|
||||
appBarTheme: const AppBarTheme(
|
||||
color: Colors.white,
|
||||
elevation: 0.0,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
iconTheme: IconThemeData(color: Colors.black),
|
||||
),
|
||||
colorScheme: ColorScheme.fromSwatch(
|
||||
primarySwatch: Colors.deepPurple,
|
||||
accentColor: purple,
|
||||
),
|
||||
tabBarTheme: const TabBarTheme(
|
||||
labelColor: Colors.black,
|
||||
unselectedLabelColor: Colors.grey,
|
||||
),
|
||||
inputDecorationTheme: inputTheme,
|
||||
tooltipTheme: tooltipTheme,
|
||||
snackBarTheme: snackBarTheme,
|
||||
);
|
||||
|
||||
final darkTheme = ThemeData(
|
||||
canvasColor: gray,
|
||||
brightness: Brightness.dark,
|
||||
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
|
||||
fontFamily: 'Inter',
|
||||
appBarTheme: const AppBarTheme(
|
||||
color: gray,
|
||||
elevation: 0.0,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
bottomNavigationBarTheme:
|
||||
const BottomNavigationBarThemeData(backgroundColor: gray),
|
||||
colorScheme: ColorScheme.fromSwatch(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Colors.deepPurple,
|
||||
accentColor: purple,
|
||||
),
|
||||
dialogBackgroundColor: gray,
|
||||
inputDecorationTheme: inputTheme,
|
||||
tooltipTheme: tooltipTheme,
|
||||
snackBarTheme: snackBarTheme,
|
||||
);
|
||||
|
||||
final oledTheme = ThemeData(
|
||||
canvasColor: Colors.black,
|
||||
splashFactory: Platform.isIOS ? NoSplash.splashFactory : null,
|
||||
fontFamily: 'Inter',
|
||||
appBarTheme: const AppBarTheme(
|
||||
color: Colors.black,
|
||||
elevation: 0.0,
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
colorScheme: ColorScheme.fromSwatch(
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Colors.deepPurple,
|
||||
accentColor: purple,
|
||||
),
|
||||
dialogBackgroundColor: Colors.black,
|
||||
inputDecorationTheme: inputTheme,
|
||||
tooltipTheme: tooltipTheme,
|
||||
snackBarTheme: snackBarTheme,
|
||||
);
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
final bool firstRun;
|
||||
|
||||
|
|
@ -239,14 +121,14 @@ class _MyAppState extends State<MyApp> {
|
|||
return Observer(
|
||||
builder: (context) {
|
||||
final settingsStore = context.read<SettingsStore>();
|
||||
final themes = FrostyThemes();
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Frosty',
|
||||
theme: lightTheme,
|
||||
darkTheme: settingsStore.themeType == ThemeType.dark ||
|
||||
settingsStore.themeType == ThemeType.system
|
||||
? darkTheme
|
||||
: oledTheme,
|
||||
theme: themes.light,
|
||||
darkTheme: settingsStore.themeType == ThemeType.black
|
||||
? themes.black
|
||||
: themes.dark,
|
||||
themeMode: settingsStore.themeType == ThemeType.system
|
||||
? ThemeMode.system
|
||||
: settingsStore.themeType == ThemeType.light
|
||||
|
|
|
|||
|
|
@ -152,5 +152,19 @@ enum BadgeType {
|
|||
twitch,
|
||||
bttv,
|
||||
ffz,
|
||||
sevenTV,
|
||||
sevenTV;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
switch (this) {
|
||||
case BadgeType.twitch:
|
||||
return 'Twitch badge';
|
||||
case BadgeType.bttv:
|
||||
return 'BetterTTV badge';
|
||||
case BadgeType.ffz:
|
||||
return 'FrankerFaceZ badge';
|
||||
case BadgeType.sevenTV:
|
||||
return '7TV badge';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,22 +184,6 @@ class Emote {
|
|||
Map<String, dynamic> toJson() => _$EmoteToJson(this);
|
||||
}
|
||||
|
||||
const emoteType = [
|
||||
'Twitch (Bits Tier)',
|
||||
'Twitch (Follower)',
|
||||
'Twitch (Subscriber)',
|
||||
'Twitch (Global)',
|
||||
'Twitch (Unlocked)',
|
||||
'Twitch (Channel)',
|
||||
'FFZ (Global)',
|
||||
'FFZ (Channel)',
|
||||
'BTTV (Global)',
|
||||
'BTTV (Channel)',
|
||||
'BTTV (Shared)',
|
||||
'7TV (Global)',
|
||||
'7TV (Channel)',
|
||||
];
|
||||
|
||||
enum EmoteType {
|
||||
twitchBits,
|
||||
twitchFollower,
|
||||
|
|
@ -213,5 +197,37 @@ enum EmoteType {
|
|||
bttvChannel,
|
||||
bttvShared,
|
||||
sevenTVGlobal,
|
||||
sevenTVChannel,
|
||||
sevenTVChannel;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
switch (this) {
|
||||
case EmoteType.twitchBits:
|
||||
return 'Twitch bits emote';
|
||||
case EmoteType.twitchFollower:
|
||||
return 'Twitch follower emote';
|
||||
case EmoteType.twitchSub:
|
||||
return 'Twitch sub emote';
|
||||
case EmoteType.twitchGlobal:
|
||||
return 'Twitch global emote';
|
||||
case EmoteType.twitchUnlocked:
|
||||
return 'Twitch unlocked emote';
|
||||
case EmoteType.twitchChannel:
|
||||
return 'Twitch channel emote';
|
||||
case EmoteType.ffzGlobal:
|
||||
return 'FrankerFaceZ global emote';
|
||||
case EmoteType.ffzChannel:
|
||||
return 'FrankerFaceZ channel emote';
|
||||
case EmoteType.bttvGlobal:
|
||||
return 'BetterTTV global emote';
|
||||
case EmoteType.bttvChannel:
|
||||
return 'BetterTTV channel emote';
|
||||
case EmoteType.bttvShared:
|
||||
return 'BetterTTV shared emote';
|
||||
case EmoteType.sevenTVGlobal:
|
||||
return '7TV global emote';
|
||||
case EmoteType.sevenTVChannel:
|
||||
return '7TV channel emote';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/models/badges.dart';
|
||||
|
|
@ -46,7 +47,8 @@ class IRCMessage {
|
|||
messages.clear();
|
||||
bufferedMessages.clear();
|
||||
messages.add(
|
||||
IRCMessage.createNotice(message: 'Chat was cleared by a moderator'));
|
||||
IRCMessage.createNotice(message: 'Chat was cleared by a moderator'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +108,8 @@ class IRCMessage {
|
|||
}
|
||||
|
||||
/// Returns an [InlineSpan] list that corresponds to the badges, username, words, and emotes of the given [IRCMessage].
|
||||
List<InlineSpan> generateSpan({
|
||||
List<InlineSpan> generateSpan(
|
||||
BuildContext context, {
|
||||
required TextStyle? style,
|
||||
required ChatAssetsStore assetsStore,
|
||||
required double badgeScale,
|
||||
|
|
@ -191,9 +194,11 @@ class IRCMessage {
|
|||
|
||||
span.add(
|
||||
_createBadgeSpan(
|
||||
context,
|
||||
badge: newBadge,
|
||||
size: badgeSize,
|
||||
backgroundColor: const Color(0xFF00AD03),
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
span.add(const TextSpan(text: ' '));
|
||||
|
|
@ -215,8 +220,10 @@ class IRCMessage {
|
|||
|
||||
span.add(
|
||||
_createBadgeSpan(
|
||||
context,
|
||||
badge: newBadge,
|
||||
size: badgeSize,
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
span.add(const TextSpan(text: ' '));
|
||||
|
|
@ -234,9 +241,11 @@ class IRCMessage {
|
|||
span.insert(
|
||||
0,
|
||||
_createBadgeSpan(
|
||||
context,
|
||||
badge: badge,
|
||||
size: badgeSize,
|
||||
backgroundColor: color,
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
span.add(const TextSpan(text: ' '));
|
||||
|
|
@ -244,9 +253,11 @@ class IRCMessage {
|
|||
} else {
|
||||
span.add(
|
||||
_createBadgeSpan(
|
||||
context,
|
||||
badge: badge,
|
||||
size: badgeSize,
|
||||
backgroundColor: color,
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
span.add(const TextSpan(text: ' '));
|
||||
|
|
@ -259,9 +270,11 @@ class IRCMessage {
|
|||
if (userBTTVBadge != null) {
|
||||
span.add(
|
||||
_createBadgeSpan(
|
||||
context,
|
||||
badge: userBTTVBadge,
|
||||
size: badgeSize,
|
||||
isSvg: true,
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
span.add(const TextSpan(text: ' '));
|
||||
|
|
@ -273,8 +286,10 @@ class IRCMessage {
|
|||
for (final badge in user7TVBadges) {
|
||||
span.add(
|
||||
_createBadgeSpan(
|
||||
context,
|
||||
badge: badge,
|
||||
size: badgeSize,
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
span.add(const TextSpan(text: ' '));
|
||||
|
|
@ -282,7 +297,8 @@ class IRCMessage {
|
|||
}
|
||||
|
||||
var color = Color(
|
||||
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')));
|
||||
int.parse((tags['color'] ?? '#868686').replaceFirst('#', '0xFF')),
|
||||
);
|
||||
|
||||
if (useReadableColors) {
|
||||
final hsl = HSLColor.fromColor(color);
|
||||
|
|
@ -385,59 +401,45 @@ class IRCMessage {
|
|||
: emoteSize,
|
||||
useFade: false,
|
||||
),
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
// Create the message for the tooltip
|
||||
final message = emoteStack.reversed.map(
|
||||
(emote) => '${emote.name} - ${emoteType[emote.type.index]}');
|
||||
(emote) => emote.name,
|
||||
);
|
||||
final emoji = words[index];
|
||||
localSpan.add(
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Tooltip(
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
showDuration: const Duration(seconds: 3),
|
||||
richMessage: WidgetSpan(
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
child: InkWell(
|
||||
onTap: () => _showDetailsBottomSheet(
|
||||
context,
|
||||
leading: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
if (nextWordIsEmoji)
|
||||
Text(
|
||||
words[index],
|
||||
style: textStyle?.copyWith(fontSize: 75),
|
||||
emoji,
|
||||
style: textStyle?.copyWith(fontSize: 40),
|
||||
),
|
||||
...emoteStack.reversed.map(
|
||||
(emote) => FrostyCachedNetworkImage(
|
||||
imageUrl: emote.url,
|
||||
height: 80,
|
||||
useFade: false,
|
||||
width: 56,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
nextWordIsEmoji
|
||||
? '${words[index]} - Emoji'
|
||||
: '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
nextWordIsEmoji
|
||||
? message.join(', ')
|
||||
: message.skip(1).join(', '),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
url: emoteStack.last.url,
|
||||
title: nextWordIsEmoji
|
||||
? emoji
|
||||
: '${emoteStack.last.name} (${emoteStack.last.type})',
|
||||
subtitle: Text(
|
||||
'with ${nextWordIsEmoji ? message.join(' + ') : message.skip(1).join(' + ')}',
|
||||
),
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
preferBelow: false,
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: children,
|
||||
|
|
@ -449,32 +451,43 @@ class IRCMessage {
|
|||
// If the next word is neither an emote or emoji, add it as a text span.
|
||||
if (nextEmote == null && !nextWordIsEmoji) {
|
||||
localSpan.add(const TextSpan(text: ' '));
|
||||
localSpan.add(_createTextSpan(
|
||||
localSpan.add(
|
||||
_createTextSpan(
|
||||
text: words[index],
|
||||
style: textStyle,
|
||||
launchExternal: launchExternal));
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
localSpan.add(
|
||||
_createEmoteSpan(
|
||||
context,
|
||||
emote: emote,
|
||||
height: emote.height != null
|
||||
? emote.height! * emoteScale
|
||||
: emoteSize,
|
||||
width: emote.width != null ? emote.width! * emoteScale : null,
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (regexEmoji.hasMatch(word)) {
|
||||
localSpan.add(_createEmojiSpan(
|
||||
localSpan.add(
|
||||
_createEmojiSpan(
|
||||
emoji: word,
|
||||
style: textStyle?.copyWith(fontSize: emoteSize - 5)));
|
||||
style: textStyle?.copyWith(fontSize: emoteSize - 5),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
localSpan.add(_createTextSpan(
|
||||
localSpan.add(
|
||||
_createTextSpan(
|
||||
text: word,
|
||||
style: textStyle,
|
||||
launchExternal: launchExternal));
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,7 +518,7 @@ class IRCMessage {
|
|||
|
||||
static Widget _createBadgeWidget({
|
||||
required ChatBadge badge,
|
||||
required double size,
|
||||
double? size,
|
||||
Color? backgroundColor,
|
||||
bool? isSvg,
|
||||
}) {
|
||||
|
|
@ -535,35 +548,30 @@ class IRCMessage {
|
|||
}
|
||||
}
|
||||
|
||||
static WidgetSpan _createBadgeSpan({
|
||||
static WidgetSpan _createBadgeSpan(
|
||||
BuildContext context, {
|
||||
required ChatBadge badge,
|
||||
required double size,
|
||||
required bool launchExternal,
|
||||
Color? backgroundColor,
|
||||
bool? isSvg,
|
||||
}) {
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Tooltip(
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
showDuration: const Duration(seconds: 3),
|
||||
richMessage: WidgetSpan(
|
||||
child: Column(
|
||||
children: [
|
||||
_createBadgeWidget(
|
||||
child: InkWell(
|
||||
onTap: () => _showDetailsBottomSheet(
|
||||
context,
|
||||
leading: _createBadgeWidget(
|
||||
badge: badge,
|
||||
size: 80,
|
||||
backgroundColor: backgroundColor,
|
||||
isSvg: isSvg,
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
Text(
|
||||
badge.name,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
url: badge.url,
|
||||
title: badge.name,
|
||||
subtitle: Text(badge.type.toString()),
|
||||
launchExternal: launchExternal,
|
||||
showCopyName: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
preferBelow: false,
|
||||
child: _createBadgeWidget(
|
||||
badge: badge,
|
||||
size: size,
|
||||
|
|
@ -574,37 +582,27 @@ class IRCMessage {
|
|||
);
|
||||
}
|
||||
|
||||
static WidgetSpan _createEmoteSpan({
|
||||
static WidgetSpan _createEmoteSpan(
|
||||
BuildContext context, {
|
||||
required Emote emote,
|
||||
required double height,
|
||||
required double? width,
|
||||
required bool launchExternal,
|
||||
}) {
|
||||
return WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Tooltip(
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
showDuration: const Duration(seconds: 3),
|
||||
richMessage: WidgetSpan(
|
||||
child: Column(
|
||||
children: [
|
||||
FrostyCachedNetworkImage(
|
||||
child: InkWell(
|
||||
onTap: () => _showDetailsBottomSheet(
|
||||
context,
|
||||
leading: FrostyCachedNetworkImage(
|
||||
imageUrl: emote.url,
|
||||
height: 80,
|
||||
useFade: false,
|
||||
width: 56,
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
Text(
|
||||
emote.name,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
url: emote.url,
|
||||
title: emote.name,
|
||||
subtitle: Text(emote.type.toString()),
|
||||
launchExternal: launchExternal,
|
||||
),
|
||||
Text(
|
||||
emoteType[emote.type.index],
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
preferBelow: false,
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: emote.url,
|
||||
height: height,
|
||||
|
|
@ -616,26 +614,96 @@ class IRCMessage {
|
|||
);
|
||||
}
|
||||
|
||||
static TextSpan _createTextSpan(
|
||||
{required String text, required bool launchExternal, TextStyle? style}) {
|
||||
static TextSpan _createTextSpan({
|
||||
required String text,
|
||||
required bool launchExternal,
|
||||
TextStyle? style,
|
||||
}) {
|
||||
if (text.startsWith('@')) {
|
||||
return TextSpan(
|
||||
text: text, style: style?.copyWith(fontWeight: FontWeight.bold));
|
||||
text: text,
|
||||
style: style?.copyWith(fontWeight: FontWeight.bold),
|
||||
);
|
||||
} else if (RegExp(r'https?:\/\/').hasMatch(text)) {
|
||||
return TextSpan(
|
||||
text: text,
|
||||
style: style?.copyWith(color: Colors.blue),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => launchUrl(Uri.parse(text),
|
||||
..onTap = () => launchUrl(
|
||||
Uri.parse(text),
|
||||
mode: launchExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppWebView),
|
||||
: LaunchMode.inAppWebView,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return TextSpan(text: text, style: style);
|
||||
}
|
||||
}
|
||||
|
||||
static void _showDetailsBottomSheet(
|
||||
BuildContext context, {
|
||||
required Widget leading,
|
||||
required String url,
|
||||
required String title,
|
||||
required Widget subtitle,
|
||||
required bool launchExternal,
|
||||
bool showCopyName = true,
|
||||
}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => ListView(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: leading,
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: subtitle,
|
||||
),
|
||||
if (showCopyName)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy_rounded),
|
||||
title: const Text('Copy name'),
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: title));
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy_rounded),
|
||||
title: const Text('Copy image URL'),
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: url));
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.launch_rounded),
|
||||
title: const Text('Open in browser'),
|
||||
onTap: () {
|
||||
launchUrl(
|
||||
Uri.parse(url),
|
||||
mode: launchExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppWebView,
|
||||
);
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses an IRC string and returns its corresponding [IRCMessage] object.
|
||||
factory IRCMessage.fromString(String whole, {String? userLogin}) {
|
||||
// We have three parts:
|
||||
|
|
@ -713,7 +781,9 @@ class IRCMessage {
|
|||
final String range;
|
||||
if (emoteIdAndPosition.contains(',')) {
|
||||
range = emoteIdAndPosition.substring(
|
||||
indexBetweenIdAndPositions + 1, emoteIdAndPosition.indexOf(','));
|
||||
indexBetweenIdAndPositions + 1,
|
||||
emoteIdAndPosition.indexOf(','),
|
||||
);
|
||||
} else {
|
||||
range = emoteIdAndPosition.substring(indexBetweenIdAndPositions + 1);
|
||||
}
|
||||
|
|
@ -780,7 +850,8 @@ class IRCMessage {
|
|||
|
||||
if (wordBuffer.isNotEmpty) {
|
||||
split.addAll(
|
||||
wordBuffer.toString().split(' ').where((element) => element != ''));
|
||||
wordBuffer.toString().split(' ').where((element) => element != ''),
|
||||
);
|
||||
}
|
||||
if (emojiBuffer.isNotEmpty) split.add(emojiBuffer.toString());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import 'package:frosty/apis/ffz_api.dart';
|
|||
import 'package:frosty/apis/seventv_api.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/screens/channel/chat/chat.dart';
|
||||
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||
|
|
@ -19,6 +18,7 @@ import 'package:frosty/screens/channel/video/video_overlay.dart';
|
|||
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/theme.dart';
|
||||
import 'package:frosty/widgets/app_bar.dart';
|
||||
import 'package:frosty/widgets/notification.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
@ -171,7 +171,7 @@ class _VideoChatState extends State<VideoChat> {
|
|||
child: IgnorePointer(
|
||||
ignoring: !videoBarVisible,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -180,7 +180,7 @@ class _VideoChatState extends State<VideoChat> {
|
|||
streamInfo: _videoStore.streamInfo!,
|
||||
tappableCategory: false,
|
||||
),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -235,7 +235,8 @@ class _VideoChatState extends State<VideoChat> {
|
|||
_chatStore.settings.chatWidth,
|
||||
color: _chatStore.settings.fullScreen
|
||||
? Colors.black.withOpacity(
|
||||
_chatStore.settings.fullScreenChatOverlayOpacity)
|
||||
_chatStore.settings.fullScreenChatOverlayOpacity,
|
||||
)
|
||||
: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: chat,
|
||||
);
|
||||
|
|
@ -244,7 +245,7 @@ class _VideoChatState extends State<VideoChat> {
|
|||
visible: settingsStore.fullScreenChatOverlay,
|
||||
maintainState: true,
|
||||
child: Theme(
|
||||
data: darkTheme,
|
||||
data: FrostyThemes().dark,
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
|
|
@ -282,7 +283,7 @@ class _VideoChatState extends State<VideoChat> {
|
|||
children: settingsStore.landscapeChatLeftSide
|
||||
? [overlayChat, Expanded(child: overlay)]
|
||||
: [Expanded(child: overlay), overlayChat],
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
|
|
@ -7,7 +8,6 @@ import 'package:frosty/screens/channel/chat/emote_menu/recent_emotes_panel.dart'
|
|||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_bottom_bar.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/page_view.dart';
|
||||
|
||||
class Chat extends StatelessWidget {
|
||||
|
|
@ -35,11 +35,14 @@ class Chat extends StatelessWidget {
|
|||
children: [
|
||||
MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaleFactor: chatStore.settings.messageScale),
|
||||
textScaleFactor: chatStore.settings.messageScale,
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: chatStore.settings.fontSize),
|
||||
child: Scrollbar(
|
||||
controller: chatStore.scrollController,
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return ListView.builder(
|
||||
|
|
@ -58,8 +61,9 @@ class Chat extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Observer(
|
||||
builder: (_) => AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
|
|
@ -67,13 +71,20 @@ class Chat extends StatelessWidget {
|
|||
switchOutCurve: Curves.easeIn,
|
||||
child: chatStore.autoScroll
|
||||
? null
|
||||
: Button(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 15.0, vertical: 10.0),
|
||||
: ElevatedButton.icon(
|
||||
onPressed: chatStore.resumeScroll,
|
||||
icon: const Icon(
|
||||
Icons.keyboard_double_arrow_down_rounded),
|
||||
child: const Text('Resume scroll'),
|
||||
icon:
|
||||
const Icon(Icons.arrow_downward_rounded),
|
||||
label: Text(
|
||||
chatStore.messageBuffer.isNotEmpty
|
||||
? '${chatStore.messageBuffer.length} new ${chatStore.messageBuffer.length == 1 ? 'message' : 'messages'}'
|
||||
: 'Resume scroll',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -111,7 +122,11 @@ class Chat extends StatelessWidget {
|
|||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: chatStore.assetsStore.showEmoteMenu
|
||||
? FrostyPageView(
|
||||
? Column(
|
||||
children: [
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: FrostyPageView(
|
||||
headers: const [
|
||||
'Recent',
|
||||
'Twitch',
|
||||
|
|
@ -131,7 +146,7 @@ class Chat extends StatelessWidget {
|
|||
...[
|
||||
chatStore.assetsStore.sevenTVEmotes,
|
||||
chatStore.assetsStore.bttvEmotes,
|
||||
chatStore.assetsStore.ffzEmotes
|
||||
chatStore.assetsStore.ffzEmotes,
|
||||
].map(
|
||||
(emotes) => EmoteMenuPanel(
|
||||
chatStore: chatStore,
|
||||
|
|
@ -139,6 +154,9 @@ class Chat extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,10 +8,8 @@ import 'package:frosty/screens/channel/chat/details/chat_modes.dart';
|
|||
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/settings/settings.dart';
|
||||
import 'package:frosty/widgets/bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/dialog.dart';
|
||||
import 'package:frosty/widgets/list_tile.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class ChatDetails extends StatelessWidget {
|
||||
final ChatDetailsStore chatDetailsStore;
|
||||
|
|
@ -25,89 +23,96 @@ class ChatDetails extends StatelessWidget {
|
|||
required this.userLogin,
|
||||
}) : super(key: key);
|
||||
|
||||
Future<void> _showSleepTimerDialog(BuildContext context) {
|
||||
return showDialog(
|
||||
String formatDuration(Duration duration) {
|
||||
if (duration.inMinutes < 60) {
|
||||
return '${duration.inMinutes} ${Intl.plural(duration.inMinutes, one: 'minute', other: 'minutes')}';
|
||||
}
|
||||
|
||||
return '${duration.inHours} ${Intl.plural(duration.inHours, one: 'hour', other: 'hours')}';
|
||||
}
|
||||
|
||||
String formatTimeLeft(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m left';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s left';
|
||||
} else {
|
||||
return '${seconds}s left';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showSleepTimer(BuildContext context) {
|
||||
const durations = [
|
||||
Duration(minutes: 5),
|
||||
Duration(minutes: 10),
|
||||
Duration(minutes: 15),
|
||||
Duration(minutes: 30),
|
||||
Duration(hours: 1),
|
||||
Duration(hours: 2),
|
||||
Duration(hours: 3),
|
||||
Duration(hours: 4),
|
||||
Duration(hours: 5),
|
||||
Duration(hours: 6),
|
||||
Duration(hours: 7),
|
||||
Duration(hours: 8),
|
||||
Duration(hours: 9),
|
||||
Duration(hours: 10),
|
||||
Duration(hours: 11),
|
||||
Duration(hours: 12),
|
||||
];
|
||||
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Sleep Timer',
|
||||
content: Observer(
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: chatStore.sleepTimer != null &&
|
||||
chatStore.sleepTimer!.isActive
|
||||
? 1.0
|
||||
: 0.5,
|
||||
child: Row(
|
||||
const SectionHeader(
|
||||
'Sleep timer',
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
const Icon(Icons.timer_rounded),
|
||||
Text(
|
||||
' ${chatStore.timeRemaining.toString().split('.')[0]}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: [FontFeature.tabularFigures()]),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: 'Cancel sleep timer',
|
||||
onPressed: chatStore.cancelSleepTimer,
|
||||
icon: const Icon(Icons.cancel_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
DropdownButton(
|
||||
value: chatStore.sleepHours,
|
||||
items: List.generate(24, (index) => index)
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e, child: Text(e.toString())))
|
||||
.toList(),
|
||||
onChanged: (int? hours) => chatStore.sleepHours = hours!,
|
||||
menuMaxHeight: 200,
|
||||
),
|
||||
const SizedBox(width: 10.0),
|
||||
const Text('Hours'),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
DropdownButton(
|
||||
value: chatStore.sleepMinutes,
|
||||
items: List.generate(60, (index) => index)
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e, child: Text(e.toString())))
|
||||
.toList(),
|
||||
onChanged: (int? minutes) =>
|
||||
chatStore.sleepMinutes = minutes!,
|
||||
menuMaxHeight: 200,
|
||||
),
|
||||
const SizedBox(width: 10.0),
|
||||
const Text('Minutes'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (chatStore.sleepTimer?.isActive == true)
|
||||
Observer(
|
||||
builder: (context) => Button(
|
||||
onPressed:
|
||||
chatStore.sleepHours == 0 && chatStore.sleepMinutes == 0
|
||||
? null
|
||||
: () => chatStore.updateSleepTimer(
|
||||
builder: (context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.close_rounded),
|
||||
title: Text(
|
||||
'Turn off (${formatTimeLeft(chatStore.timeRemaining)})',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
chatStore.cancelSleepTimer();
|
||||
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
...durations.map(
|
||||
(duration) => ListTile(
|
||||
leading: const Icon(Icons.hourglass_top_rounded),
|
||||
title: Text(formatDuration(duration)),
|
||||
onTap: () {
|
||||
chatStore.updateSleepTimer(
|
||||
duration: duration,
|
||||
onTimerFinished: () => navigatorKey.currentState
|
||||
?.popUntil((route) => route.isFirst),
|
||||
),
|
||||
child: const Text('Set timer'),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
Button(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
color: Colors.grey,
|
||||
child: const Text('Close'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -117,19 +122,19 @@ class ChatDetails extends StatelessWidget {
|
|||
Future<void> _showClearDialog(BuildContext context) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Clear Recent Emotes',
|
||||
message: 'Are you sure you want to clear your recent emotes?',
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: const Text('Clear recent emotes'),
|
||||
content:
|
||||
const Text('Are you sure you want to clear your recent emotes?'),
|
||||
actions: [
|
||||
Button(
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
Button(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
color: Colors.grey,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -138,18 +143,24 @@ class ChatDetails extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = [
|
||||
const SectionHeader(
|
||||
'Chat modes',
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
),
|
||||
ListTile(
|
||||
title: ChatModes(roomState: chatDetailsStore.roomState),
|
||||
),
|
||||
FrostyListTile(
|
||||
const SectionHeader(
|
||||
'More',
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.people_outline),
|
||||
title: 'Chatters',
|
||||
title: const Text('Chatters'),
|
||||
onTap: () => showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => FrostyBottomSheet(
|
||||
child: SizedBox(
|
||||
builder: (context) => SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
child: GestureDetector(
|
||||
onTap: FocusScope.of(context).unfocus,
|
||||
|
|
@ -162,38 +173,46 @@ class ChatDetails extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FrostyListTile(
|
||||
Observer(
|
||||
builder: (context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.timer_outlined),
|
||||
title: 'Sleep timer',
|
||||
onTap: () => _showSleepTimerDialog(context),
|
||||
title: Text(
|
||||
'Sleep timer ${chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(chatStore.timeRemaining)})' : ''}',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
FrostyListTile(
|
||||
),
|
||||
onTap: () => _showSleepTimer(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline_rounded),
|
||||
title: 'Clear recent emotes',
|
||||
title: const Text('Clear recent emotes'),
|
||||
onTap: () => _showClearDialog(context),
|
||||
),
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh_rounded),
|
||||
title: 'Reconnect to chat',
|
||||
title: const Text('Reconnect to chat'),
|
||||
onTap: () {
|
||||
chatStore.updateNotification('Reconnecting to chat...');
|
||||
|
||||
chatStore.connectToChat();
|
||||
},
|
||||
),
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh_rounded),
|
||||
title: 'Refresh badges and emotes',
|
||||
title: const Text('Refresh badges and emotes'),
|
||||
onTap: () async {
|
||||
await chatStore.getAssets();
|
||||
|
||||
chatStore.updateNotification('Badges and emotes refreshed');
|
||||
},
|
||||
),
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: 'Settings',
|
||||
title: const Text('Settings'),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -203,16 +222,10 @@ class ChatDetails extends StatelessWidget {
|
|||
),
|
||||
];
|
||||
|
||||
return FrostyBottomSheet(
|
||||
child: MediaQuery.of(context).orientation == Orientation.landscape
|
||||
? SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.8,
|
||||
child: ListView(
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
children: children,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class _ChattersListState extends State<ChattersList> {
|
|||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return TextField(
|
||||
|
|
@ -41,7 +41,6 @@ class _ChattersListState extends State<ChattersList> {
|
|||
focusNode: widget.chatDetailsStore.textFieldFocusNode,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
prefixIcon: const Icon(Icons.filter_list_rounded),
|
||||
hintText: 'Filter chatters',
|
||||
suffixIcon: widget
|
||||
|
|
@ -67,7 +66,7 @@ class _ChattersListState extends State<ChattersList> {
|
|||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
child: RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
|
|
@ -76,7 +75,9 @@ class _ChattersListState extends State<ChattersList> {
|
|||
child: Stack(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
children: [
|
||||
Observer(
|
||||
Scrollbar(
|
||||
controller: widget.chatDetailsStore.scrollController,
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
|
|
@ -84,12 +85,7 @@ class _ChattersListState extends State<ChattersList> {
|
|||
slivers: [
|
||||
if (widget.chatDetailsStore.filterText.isEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 10.0,
|
||||
right: 10.0,
|
||||
top: 20.0,
|
||||
bottom: 10.0,
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
'${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found',
|
||||
|
|
@ -103,17 +99,14 @@ class _ChattersListState extends State<ChattersList> {
|
|||
if (widget.chatDetailsStore.chatUsers.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: AlertMessage(message: 'No chatters found'),
|
||||
),
|
||||
)
|
||||
else if (widget.chatDetailsStore.filteredUsers.isEmpty)
|
||||
else if (widget
|
||||
.chatDetailsStore.filteredUsers.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child:
|
||||
AlertMessage(message: 'No matching chatters'),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
|
|
@ -121,28 +114,31 @@ class _ChattersListState extends State<ChattersList> {
|
|||
(context, index) => InkWell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0, vertical: 5.0),
|
||||
child: Text(widget
|
||||
.chatDetailsStore.filteredUsers
|
||||
.elementAt(index)),
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
onLongPress: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
final userInfo = await context
|
||||
.read<TwitchApi>()
|
||||
.getUser(
|
||||
child: Text(
|
||||
widget.chatDetailsStore.filteredUsers
|
||||
.elementAt(index),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
final userInfo =
|
||||
await context.read<TwitchApi>().getUser(
|
||||
headers: context
|
||||
.read<AuthStore>()
|
||||
.headersTwitch,
|
||||
userLogin: widget
|
||||
.chatDetailsStore.filteredUsers
|
||||
.elementAt(index));
|
||||
userLogin: widget.chatDetailsStore
|
||||
.filteredUsers
|
||||
.elementAt(index),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => ChatUserModal(
|
||||
|
|
@ -154,14 +150,15 @@ class _ChattersListState extends State<ChattersList> {
|
|||
);
|
||||
},
|
||||
),
|
||||
childCount:
|
||||
widget.chatDetailsStore.filteredUsers.length,
|
||||
childCount: widget
|
||||
.chatDetailsStore.filteredUsers.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Observer(
|
||||
builder: (context) => AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
|
|
@ -170,7 +167,8 @@ class _ChattersListState extends State<ChattersList> {
|
|||
child: widget.chatDetailsStore.showJumpButton
|
||||
? ScrollToTopButton(
|
||||
scrollController:
|
||||
widget.chatDetailsStore.scrollController)
|
||||
widget.chatDetailsStore.scrollController,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,18 +20,22 @@ class EmoteMenuPanel extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
if (emotes != null) {
|
||||
final globalEmotes = emotes!
|
||||
.where((emote) =>
|
||||
.where(
|
||||
(emote) =>
|
||||
emote.type == EmoteType.bttvGlobal ||
|
||||
emote.type == EmoteType.ffzGlobal ||
|
||||
emote.type == EmoteType.sevenTVGlobal)
|
||||
emote.type == EmoteType.sevenTVGlobal,
|
||||
)
|
||||
.toList();
|
||||
|
||||
final channelEmotes = emotes!
|
||||
.where((emote) =>
|
||||
.where(
|
||||
(emote) =>
|
||||
emote.type == EmoteType.bttvChannel ||
|
||||
emote.type == EmoteType.bttvShared ||
|
||||
emote.type == EmoteType.ffzChannel ||
|
||||
emote.type == EmoteType.sevenTVChannel)
|
||||
emote.type == EmoteType.sevenTVChannel,
|
||||
)
|
||||
.toList();
|
||||
|
||||
return FrostyPageView(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart';
|
|||
import 'package:frosty/widgets/cached_image.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EmoteMenuSection extends StatelessWidget {
|
||||
class EmoteMenuSection extends StatefulWidget {
|
||||
final ChatStore chatStore;
|
||||
final List<Emote> emotes;
|
||||
|
||||
|
|
@ -16,8 +16,16 @@ class EmoteMenuSection extends StatelessWidget {
|
|||
required this.emotes,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EmoteMenuSection> createState() => _EmoteMenuSectionState();
|
||||
}
|
||||
|
||||
class _EmoteMenuSectionState extends State<EmoteMenuSection>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
|
|
@ -28,23 +36,27 @@ class EmoteMenuSection extends StatelessWidget {
|
|||
: 16,
|
||||
),
|
||||
itemBuilder: (context, index) => InkWell(
|
||||
onTap: () => chatStore.addEmote(emotes[index]),
|
||||
onTap: () => widget.chatStore.addEmote(widget.emotes[index]),
|
||||
child: Tooltip(
|
||||
message: emotes[index].name,
|
||||
message: widget.emotes[index].name,
|
||||
preferBelow: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
child: Center(
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: emotes[index].url,
|
||||
height: emotes[index].height?.toDouble() ?? defaultEmoteSize,
|
||||
width: emotes[index].width?.toDouble(),
|
||||
imageUrl: widget.emotes[index].url,
|
||||
height:
|
||||
widget.emotes[index].height?.toDouble() ?? defaultEmoteSize,
|
||||
width: widget.emotes[index].width?.toDouble(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
itemCount: emotes.length,
|
||||
itemCount: widget.emotes.length,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,13 @@ class RecentEmotesPanel extends StatelessWidget {
|
|||
final emote = chatStore.assetsStore.recentEmotes[index];
|
||||
final validEmotes = [
|
||||
...chatStore.assetsStore.emoteToObject.values,
|
||||
...chatStore.assetsStore.userEmoteToObject.values
|
||||
...chatStore.assetsStore.userEmoteToObject.values,
|
||||
];
|
||||
final matchingEmotes = validEmotes.where((existingEmote) =>
|
||||
final matchingEmotes = validEmotes.where(
|
||||
(existingEmote) =>
|
||||
existingEmote.name == emote.name &&
|
||||
existingEmote.type == emote.type);
|
||||
existingEmote.type == emote.type,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: matchingEmotes.isNotEmpty
|
||||
|
|
@ -52,7 +54,7 @@ class RecentEmotesPanel extends StatelessWidget {
|
|||
message: emote.name,
|
||||
preferBelow: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Center(
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: matchingEmotes.isNotEmpty
|
||||
|
|
|
|||
|
|
@ -109,8 +109,10 @@ abstract class ChatAssetsStoreBase with Store {
|
|||
|
||||
_disposeReaction = autorun((_) {
|
||||
if (_recentEmotes.length > 48) _recentEmotes.removeLast();
|
||||
prefs.setStringList('recent_emotes',
|
||||
_recentEmotes.map((emote) => jsonEncode(emote)).toList());
|
||||
prefs.setStringList(
|
||||
'recent_emotes',
|
||||
_recentEmotes.map((emote) => jsonEncode(emote)).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -162,8 +164,11 @@ abstract class ChatAssetsStoreBase with Store {
|
|||
ffzRoomInfo = roomInfo;
|
||||
return emotes;
|
||||
}).catchError(onError),
|
||||
]).then((assets) => assets.expand((list) => list)).then((emotes) =>
|
||||
_emoteToObject = {for (final emote in emotes) emote.name: emote});
|
||||
]).then((assets) => assets.expand((list) => list)).then(
|
||||
(emotes) => _emoteToObject = {
|
||||
for (final emote in emotes) emote.name: emote,
|
||||
},
|
||||
);
|
||||
|
||||
@action
|
||||
Future<void> badgesFuture({
|
||||
|
|
@ -177,10 +182,12 @@ abstract class ChatAssetsStoreBase with Store {
|
|||
twitchApi
|
||||
.getBadgesGlobal(headers: headers)
|
||||
.then((badges) => twitchBadgesToObject.addAll(badges))
|
||||
.then((_) => twitchApi
|
||||
.then(
|
||||
(_) => twitchApi
|
||||
.getBadgesChannel(id: channelId, headers: headers)
|
||||
.then((badges) => twitchBadgesToObject.addAll(badges))
|
||||
.catchError(onError)),
|
||||
.catchError(onError),
|
||||
),
|
||||
ffzApi
|
||||
.getBadges()
|
||||
.then((badges) => _userToFFZBadges = badges)
|
||||
|
|
@ -201,15 +208,21 @@ abstract class ChatAssetsStoreBase with Store {
|
|||
required Map<String, String> headers,
|
||||
required Function onError,
|
||||
}) async {
|
||||
final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi
|
||||
final userEmotes = await Future.wait(
|
||||
emoteSets.map(
|
||||
(setId) => twitchApi
|
||||
.getEmotesSets(setId: setId, headers: headers)
|
||||
.catchError(onError)));
|
||||
.catchError(onError),
|
||||
),
|
||||
);
|
||||
|
||||
for (final emoteSet in userEmotes) {
|
||||
if (emoteSet.isNotEmpty) {
|
||||
if (emoteSet.first.type == EmoteType.twitchSub) {
|
||||
final owner = await twitchApi.getUser(
|
||||
id: emoteSet.first.ownerId, headers: headers);
|
||||
id: emoteSet.first.ownerId,
|
||||
headers: headers,
|
||||
);
|
||||
_userEmoteSectionToEmotes.update(
|
||||
owner.displayName,
|
||||
(existingEmoteSet) => [...existingEmoteSet, ...emoteSet],
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
|||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:wakelock/wakelock.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
part 'chat_store.g.dart';
|
||||
|
|
@ -80,7 +80,7 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
/// The list of chat messages to add once autoscroll is resumed.
|
||||
/// This is used as an optimization to prevent the list from being updated/shifted while the user is scrolling.
|
||||
final _messageBuffer = <IRCMessage>[];
|
||||
final messageBuffer = ObservableList<IRCMessage>();
|
||||
|
||||
/// Timer used for dismissing the notification.
|
||||
Timer? _notificationTimer;
|
||||
|
|
@ -88,14 +88,6 @@ abstract class ChatStoreBase with Store {
|
|||
/// The current timer for the sleep timer if active.
|
||||
Timer? sleepTimer;
|
||||
|
||||
/// The amount of hours the sleep timer is set to.
|
||||
@observable
|
||||
var sleepHours = 0;
|
||||
|
||||
/// The amount of minutes the sleep timer is set to.
|
||||
@observable
|
||||
var sleepMinutes = 0;
|
||||
|
||||
/// The time remaining for the sleep timer.
|
||||
@observable
|
||||
var timeRemaining = const Duration();
|
||||
|
|
@ -156,7 +148,7 @@ abstract class ChatStoreBase with Store {
|
|||
required this.displayName,
|
||||
}) {
|
||||
// Enable wakelock to prevent the chat from sleeping.
|
||||
if (settings.chatOnlyPreventSleep) Wakelock.enable();
|
||||
if (settings.chatOnlyPreventSleep) WakelockPlus.enable();
|
||||
|
||||
// Create a reaction that will reconnect to chat when logging in or out.
|
||||
// Closing the channel will trigger a reconnect with the new credentials.
|
||||
|
|
@ -169,17 +161,22 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
// Create a timer that will add messages from the buffer every 200 milliseconds.
|
||||
_messageBufferTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 200), (timer) => addMessages());
|
||||
const Duration(milliseconds: 200),
|
||||
(timer) => addMessages(),
|
||||
);
|
||||
|
||||
assetsStore.init();
|
||||
|
||||
_messageBuffer
|
||||
messageBuffer
|
||||
.add(IRCMessage.createNotice(message: 'Connecting to chat...'));
|
||||
|
||||
if (settings.chatDelay > 0) {
|
||||
_messageBuffer.add(IRCMessage.createNotice(
|
||||
messageBuffer.add(
|
||||
IRCMessage.createNotice(
|
||||
message:
|
||||
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...'));
|
||||
'Waiting ${settings.chatDelay.toInt()} ${settings.chatDelay == 1.0 ? 'second' : 'seconds'} due to message delay setting...',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
connectToChat();
|
||||
|
|
@ -242,8 +239,10 @@ abstract class ChatStoreBase with Store {
|
|||
if (!_userState.mod &&
|
||||
channelName != auth.user.details?.login &&
|
||||
auth.user.blockedUsers
|
||||
.where((blockedUser) =>
|
||||
blockedUser.userLogin == parsedIRCMessage.user)
|
||||
.where(
|
||||
(blockedUser) =>
|
||||
blockedUser.userLogin == parsedIRCMessage.user,
|
||||
)
|
||||
.isNotEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -252,19 +251,19 @@ abstract class ChatStoreBase with Store {
|
|||
case Command.privateMessage:
|
||||
case Command.notice:
|
||||
case Command.userNotice:
|
||||
_messageBuffer.add(parsedIRCMessage);
|
||||
messageBuffer.add(parsedIRCMessage);
|
||||
break;
|
||||
case Command.clearChat:
|
||||
IRCMessage.clearChat(
|
||||
messages: _messages,
|
||||
bufferedMessages: _messageBuffer,
|
||||
bufferedMessages: messageBuffer,
|
||||
ircMessage: parsedIRCMessage,
|
||||
);
|
||||
break;
|
||||
case Command.clearMessage:
|
||||
IRCMessage.clearMessage(
|
||||
messages: _messages,
|
||||
bufferedMessages: _messageBuffer,
|
||||
bufferedMessages: messageBuffer,
|
||||
ircMessage: parsedIRCMessage,
|
||||
);
|
||||
break;
|
||||
|
|
@ -277,7 +276,7 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
if (toSend != null) {
|
||||
textController.clear();
|
||||
_messageBuffer.add(toSend!);
|
||||
messageBuffer.add(toSend!);
|
||||
toSend = null;
|
||||
}
|
||||
break;
|
||||
|
|
@ -301,9 +300,9 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
if (!_autoScroll) {
|
||||
// While autoscroll is disabled, occasionally move messages from the buffer to the messages to prevent a memory leak.
|
||||
if (_messageBuffer.length >= _messagesToRemove) {
|
||||
_messages.addAll(_messageBuffer);
|
||||
_messageBuffer.clear();
|
||||
if (messageBuffer.length >= _messagesToRemove) {
|
||||
_messages.addAll(messageBuffer);
|
||||
messageBuffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -315,9 +314,12 @@ abstract class ChatStoreBase with Store {
|
|||
_channel?.sink.add('PONG :tmi.twitch.tv');
|
||||
return;
|
||||
} else if (message.contains('Welcome, GLHF!')) {
|
||||
_messageBuffer.add(IRCMessage.createNotice(
|
||||
messageBuffer.add(
|
||||
IRCMessage.createNotice(
|
||||
message:
|
||||
"Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!"));
|
||||
"Connected to $displayName${regexEnglish.hasMatch(displayName) ? '' : ' ($channelName)'}'s chat!",
|
||||
),
|
||||
);
|
||||
|
||||
getAssets();
|
||||
|
||||
|
|
@ -365,8 +367,10 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
// Listen for new messages and forward them to the handler.
|
||||
_channelListener = _channel?.stream.listen(
|
||||
(data) => Future.delayed(Duration(seconds: settings.chatDelay.toInt()),
|
||||
() => _handleIRCData(data.toString())),
|
||||
(data) => Future.delayed(
|
||||
Duration(seconds: settings.chatDelay.toInt()),
|
||||
() => _handleIRCData(data.toString()),
|
||||
),
|
||||
onError: (error) => debugPrint('Chat error: ${error.toString()}'),
|
||||
onDone: () async {
|
||||
if (_channel == null) return;
|
||||
|
|
@ -375,7 +379,7 @@ abstract class ChatStoreBase with Store {
|
|||
// Add notice that chat was disconnected and then wait the backoff time before reconnecting.
|
||||
final notice =
|
||||
'Disconnected from chat, waiting $_backoffTime ${_backoffTime == 1 ? 'second' : 'seconds'} before reconnecting...';
|
||||
_messageBuffer.add(IRCMessage.createNotice(message: notice));
|
||||
messageBuffer.add(IRCMessage.createNotice(message: notice));
|
||||
}
|
||||
|
||||
await Future.delayed(Duration(seconds: _backoffTime));
|
||||
|
|
@ -385,8 +389,11 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
// Increment the retry count and attempt the reconnect.
|
||||
_retries++;
|
||||
_messageBuffer.add(IRCMessage.createNotice(
|
||||
message: 'Reconnecting to chat (attempt $_retries)...'));
|
||||
messageBuffer.add(
|
||||
IRCMessage.createNotice(
|
||||
message: 'Reconnecting to chat (attempt $_retries)...',
|
||||
),
|
||||
);
|
||||
_channelListener?.cancel();
|
||||
connectToChat();
|
||||
},
|
||||
|
|
@ -416,10 +423,10 @@ abstract class ChatStoreBase with Store {
|
|||
|
||||
@action
|
||||
void addMessages() {
|
||||
if (!_autoScroll || _messageBuffer.isEmpty) return;
|
||||
if (!_autoScroll || messageBuffer.isEmpty) return;
|
||||
|
||||
_messages.addAll(_messageBuffer);
|
||||
_messageBuffer.clear();
|
||||
_messages.addAll(messageBuffer);
|
||||
messageBuffer.clear();
|
||||
}
|
||||
|
||||
/// Sends the given string message by the logged-in user and adds it to [_messages].
|
||||
|
|
@ -429,8 +436,11 @@ abstract class ChatStoreBase with Store {
|
|||
if (message.isEmpty) return;
|
||||
|
||||
if (_channel == null || _channel?.closeCode != null) {
|
||||
_messageBuffer.add(IRCMessage.createNotice(
|
||||
message: 'Failed to send message: disconnected from chat.'));
|
||||
messageBuffer.add(
|
||||
IRCMessage.createNotice(
|
||||
message: 'Failed to send message: disconnected from chat.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Send the message to the IRC chat room.
|
||||
_channel?.sink.add('PRIVMSG #$channelName :$message');
|
||||
|
|
@ -474,12 +484,15 @@ abstract class ChatStoreBase with Store {
|
|||
}
|
||||
|
||||
assetsStore.recentEmotes
|
||||
..removeWhere((recentEmote) =>
|
||||
recentEmote.name == emote.name && recentEmote.type == emote.type)
|
||||
..removeWhere(
|
||||
(recentEmote) =>
|
||||
recentEmote.name == emote.name && recentEmote.type == emote.type,
|
||||
)
|
||||
..insert(0, emote);
|
||||
|
||||
textController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: textController.text.length));
|
||||
TextPosition(offset: textController.text.length),
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage].
|
||||
|
|
@ -501,22 +514,18 @@ abstract class ChatStoreBase with Store {
|
|||
Timer(const Duration(seconds: 2), () => _notification = null);
|
||||
}
|
||||
|
||||
/// Updates the sleep timer with [sleepHours] and [sleepMinutes].
|
||||
/// Updates the sleep timer with the given [duration].
|
||||
/// Calls [onTimerFinished] when the sleep timer completes.
|
||||
@action
|
||||
void updateSleepTimer({required void Function() onTimerFinished}) {
|
||||
// If hours and minutes are 0, do nothing.
|
||||
if (sleepHours == 0 && sleepMinutes == 0) return;
|
||||
|
||||
void updateSleepTimer({
|
||||
required Duration duration,
|
||||
required VoidCallback onTimerFinished,
|
||||
}) {
|
||||
// If there is an ongoing timer, cancel it since it'll be replaced.
|
||||
if (sleepTimer != null) cancelSleepTimer();
|
||||
|
||||
// Update the new time remaining
|
||||
timeRemaining = Duration(hours: sleepHours, minutes: sleepMinutes);
|
||||
|
||||
// Reset the hours and minutes in the dropdown buttons.
|
||||
sleepHours = 0;
|
||||
sleepMinutes = 0;
|
||||
timeRemaining = duration;
|
||||
|
||||
// Set a periodic timer that will update the time remaining every second.
|
||||
sleepTimer = Timer.periodic(
|
||||
|
|
@ -564,6 +573,6 @@ abstract class ChatStoreBase with Store {
|
|||
chatDetailsStore.dispose();
|
||||
|
||||
// Disable wakelock so that the sleep timer will function properly.
|
||||
Wakelock.disable();
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,38 +17,6 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
name: 'ChatStoreBase.renderMessages'))
|
||||
.value;
|
||||
|
||||
late final _$sleepHoursAtom =
|
||||
Atom(name: 'ChatStoreBase.sleepHours', context: context);
|
||||
|
||||
@override
|
||||
int get sleepHours {
|
||||
_$sleepHoursAtom.reportRead();
|
||||
return super.sleepHours;
|
||||
}
|
||||
|
||||
@override
|
||||
set sleepHours(int value) {
|
||||
_$sleepHoursAtom.reportWrite(value, super.sleepHours, () {
|
||||
super.sleepHours = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$sleepMinutesAtom =
|
||||
Atom(name: 'ChatStoreBase.sleepMinutes', context: context);
|
||||
|
||||
@override
|
||||
int get sleepMinutes {
|
||||
_$sleepMinutesAtom.reportRead();
|
||||
return super.sleepMinutes;
|
||||
}
|
||||
|
||||
@override
|
||||
set sleepMinutes(int value) {
|
||||
_$sleepMinutesAtom.reportWrite(value, super.sleepMinutes, () {
|
||||
super.sleepMinutes = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$timeRemainingAtom =
|
||||
Atom(name: 'ChatStoreBase.timeRemaining', context: context);
|
||||
|
||||
|
|
@ -316,11 +284,13 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
}
|
||||
|
||||
@override
|
||||
void updateSleepTimer({required void Function() onTimerFinished}) {
|
||||
void updateSleepTimer(
|
||||
{required Duration duration, required VoidCallback onTimerFinished}) {
|
||||
final _$actionInfo = _$ChatStoreBaseActionController.startAction(
|
||||
name: 'ChatStoreBase.updateSleepTimer');
|
||||
try {
|
||||
return super.updateSleepTimer(onTimerFinished: onTimerFinished);
|
||||
return super.updateSleepTimer(
|
||||
duration: duration, onTimerFinished: onTimerFinished);
|
||||
} finally {
|
||||
_$ChatStoreBaseActionController.endAction(_$actionInfo);
|
||||
}
|
||||
|
|
@ -340,8 +310,6 @@ mixin _$ChatStore on ChatStoreBase, Store {
|
|||
@override
|
||||
String toString() {
|
||||
return '''
|
||||
sleepHours: ${sleepHours},
|
||||
sleepMinutes: ${sleepMinutes},
|
||||
timeRemaining: ${timeRemaining},
|
||||
expandChat: ${expandChat},
|
||||
renderMessages: ${renderMessages}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import 'package:flutter_mobx/flutter_mobx.dart';
|
|||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/screens/channel/chat/details/chat_details.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
|
||||
class ChatBottomBar extends StatelessWidget {
|
||||
|
|
@ -20,9 +19,11 @@ class ChatBottomBar extends StatelessWidget {
|
|||
color: chatStore.assetsStore.showEmoteMenu
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: null,
|
||||
icon: Icon(chatStore.assetsStore.showEmoteMenu
|
||||
icon: Icon(
|
||||
chatStore.assetsStore.showEmoteMenu
|
||||
? Icons.emoji_emotions_rounded
|
||||
: Icons.emoji_emotions_outlined),
|
||||
: Icons.emoji_emotions_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
FocusScope.of(context).unfocus();
|
||||
chatStore.assetsStore.showEmoteMenu =
|
||||
|
|
@ -37,7 +38,7 @@ class ChatBottomBar extends StatelessWidget {
|
|||
...chatStore.assetsStore.userEmoteToObject.values,
|
||||
...chatStore.assetsStore.bttvEmotes,
|
||||
...chatStore.assetsStore.ffzEmotes,
|
||||
...chatStore.assetsStore.sevenTVEmotes
|
||||
...chatStore.assetsStore.sevenTVEmotes,
|
||||
]
|
||||
.where(
|
||||
(emote) => emote.name.toLowerCase().contains(
|
||||
|
|
@ -63,24 +64,23 @@ class ChatBottomBar extends StatelessWidget {
|
|||
if (chatStore.settings.autocomplete &&
|
||||
chatStore.showEmoteAutocomplete &&
|
||||
matchingEmotes.isNotEmpty) ...[
|
||||
const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
),
|
||||
const Divider(),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5.0),
|
||||
padding: const EdgeInsets.all(4),
|
||||
itemCount: matchingEmotes.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) => InkWell(
|
||||
onTap: () => chatStore.addEmote(matchingEmotes[index],
|
||||
autocompleteMode: true),
|
||||
onTap: () => chatStore.addEmote(
|
||||
matchingEmotes[index],
|
||||
autocompleteMode: true,
|
||||
),
|
||||
child: Tooltip(
|
||||
message: matchingEmotes[index].name,
|
||||
preferBelow: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Center(
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: matchingEmotes[index].url,
|
||||
|
|
@ -99,18 +99,17 @@ class ChatBottomBar extends StatelessWidget {
|
|||
if (chatStore.settings.autocomplete &&
|
||||
chatStore.showMentionAutocomplete &&
|
||||
matchingChatters.isNotEmpty) ...[
|
||||
const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
),
|
||||
const Divider(),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5.0),
|
||||
padding: const EdgeInsets.all(4),
|
||||
itemCount: matchingChatters.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) => Button(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
itemBuilder: (context, index) => TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onPressed: () {
|
||||
final split = chatStore.textController.text.split(' ')
|
||||
..removeLast()
|
||||
|
|
@ -118,16 +117,19 @@ class ChatBottomBar extends StatelessWidget {
|
|||
|
||||
chatStore.textController.text = split.join(' ');
|
||||
chatStore.textController.selection =
|
||||
TextSelection.fromPosition(TextPosition(
|
||||
offset: chatStore.textController.text.length));
|
||||
TextSelection.fromPosition(
|
||||
TextPosition(
|
||||
offset: chatStore.textController.text.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(matchingChatters[index]),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10.0, 10.0, 0.0, 10.0),
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 0, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (!chatStore.expandChat &&
|
||||
|
|
@ -152,7 +154,6 @@ class ChatBottomBar extends StatelessWidget {
|
|||
maxLines: 5,
|
||||
enabled: chatStore.auth.isLoggedIn ? true : false,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.only(left: 15.0),
|
||||
prefixIcon: chatStore.settings.emoteMenuButtonOnLeft
|
||||
? emoteMenuButton
|
||||
: null,
|
||||
|
|
@ -186,7 +187,6 @@ class ChatBottomBar extends StatelessWidget {
|
|||
icon: Icon(Icons.adaptive.more_rounded),
|
||||
tooltip: 'More',
|
||||
onPressed: () => showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (_) => ChatDetails(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:frosty/constants.dart';
|
|||
import 'package:frosty/models/irc.dart';
|
||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
|
||||
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
|
||||
|
||||
class ChatMessage extends StatelessWidget {
|
||||
final IRCMessage ircMessage;
|
||||
|
|
@ -29,7 +30,6 @@ class ChatMessage extends StatelessWidget {
|
|||
ircMessage.user == chatStore.auth.user.details?.login) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => ChatUserModal(
|
||||
|
|
@ -69,6 +69,7 @@ class ChatMessage extends StatelessWidget {
|
|||
final messageSpan = Text.rich(
|
||||
TextSpan(
|
||||
children: ircMessage.generateSpan(
|
||||
context,
|
||||
onTapName: onTapName,
|
||||
style: defaultTextStyle,
|
||||
assetsStore: chatStore.assetsStore,
|
||||
|
|
@ -98,19 +99,26 @@ class ChatMessage extends StatelessWidget {
|
|||
Icon(
|
||||
replyUser != null && replyBody != null
|
||||
? Icons.reply_rounded
|
||||
: Icons.new_releases_outlined,
|
||||
: Icons.star_rounded,
|
||||
size: defaultBadgeSize * chatStore.settings.badgeScale,
|
||||
color: defaultTextStyle.color?.withOpacity(0.5),
|
||||
textDirection: TextDirection.rtl,
|
||||
),
|
||||
const SizedBox(width: 5.0),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: replyUser != null && replyBody != null
|
||||
? Tooltip(
|
||||
message: 'Replying to @$replyUser: $replyBody',
|
||||
preferBelow: false,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
showDuration: const Duration(seconds: 5),
|
||||
? GestureDetector(
|
||||
onTap: isModal
|
||||
? null
|
||||
: () => showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ReplyThread(
|
||||
selectedMessage: ircMessage,
|
||||
chatStore: chatStore,
|
||||
);
|
||||
},
|
||||
),
|
||||
child: Text(
|
||||
'Replying to @$replyUser: $replyBody',
|
||||
maxLines: 1,
|
||||
|
|
@ -124,7 +132,7 @@ class ChatMessage extends StatelessWidget {
|
|||
: Text(
|
||||
'First time chatting',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
defaultTextStyle.color?.withOpacity(0.5),
|
||||
),
|
||||
|
|
@ -132,7 +140,7 @@ class ChatMessage extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
const SizedBox(height: 4),
|
||||
messageSpan,
|
||||
],
|
||||
);
|
||||
|
|
@ -155,22 +163,23 @@ class ChatMessage extends StatelessWidget {
|
|||
if (ircMessage.command == Command.clearMessage)
|
||||
const Text(
|
||||
'Message deleted',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
)
|
||||
else
|
||||
const Text(
|
||||
'Permanently banned',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Timed out for $banDuration ${int.parse(banDuration) > 1 ? 'seconds' : 'second'}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: ircMessage.generateSpan(
|
||||
context,
|
||||
onTapName: onTapName,
|
||||
style: defaultTextStyle,
|
||||
assetsStore: chatStore.assetsStore,
|
||||
|
|
@ -206,8 +215,9 @@ class ChatMessage extends StatelessWidget {
|
|||
Text(
|
||||
ircMessage.tags['system-msg']!,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: defaultTextStyle.color?.withOpacity(0.5)),
|
||||
fontWeight: FontWeight.w500,
|
||||
color: defaultTextStyle.color?.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
if (ircMessage.tags['msg-id'] == 'announcement')
|
||||
Row(
|
||||
|
|
@ -217,18 +227,19 @@ class ChatMessage extends StatelessWidget {
|
|||
size:
|
||||
defaultBadgeSize * chatStore.settings.badgeScale,
|
||||
),
|
||||
const SizedBox(width: 5.0),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Announcement',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
const SizedBox(height: 4),
|
||||
if (ircMessage.message != null)
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: ircMessage.generateSpan(
|
||||
context,
|
||||
onTapName: onTapName,
|
||||
style: defaultTextStyle,
|
||||
assetsStore: chatStore.assetsStore,
|
||||
|
|
@ -256,7 +267,8 @@ class ChatMessage extends StatelessWidget {
|
|||
final paddedMessage = Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: chatStore.settings.messageSpacing / 2,
|
||||
horizontal: 10.0),
|
||||
horizontal: 12,
|
||||
),
|
||||
child: renderMessage,
|
||||
);
|
||||
|
||||
|
|
@ -266,10 +278,7 @@ class ChatMessage extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
paddedMessage,
|
||||
const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
)
|
||||
: paddedMessage;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
|||
import 'package:frosty/screens/channel/chat/widgets/chat_message.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/block_report_modal.dart';
|
||||
import 'package:frosty/widgets/bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
|
||||
class ChatUserModal extends StatefulWidget {
|
||||
final ChatStore chatStore;
|
||||
|
|
@ -35,14 +32,13 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
? widget.displayName
|
||||
: '${widget.displayName} (${widget.username})';
|
||||
|
||||
return FrostyBottomSheet(
|
||||
child: SizedBox(
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.all(10.0),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
leading: ProfilePicture(
|
||||
userLogin: widget.username,
|
||||
),
|
||||
|
|
@ -60,20 +56,23 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
),
|
||||
],
|
||||
),
|
||||
trailing: widget.chatStore.auth.isLoggedIn
|
||||
? Button(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15.0),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.chatStore.auth.isLoggedIn)
|
||||
IconButton(
|
||||
tooltip: 'Reply',
|
||||
onPressed: () {
|
||||
widget.chatStore.textController.text =
|
||||
'@${widget.username} ';
|
||||
Navigator.pop(context);
|
||||
widget.chatStore.textFieldFocusNode.requestFocus();
|
||||
},
|
||||
child: const Text('Reply'),
|
||||
)
|
||||
: null,
|
||||
onTap: () => showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
icon: const Icon(Icons.reply_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'More',
|
||||
onPressed: () => showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => BlockReportModal(
|
||||
authStore: widget.chatStore.auth,
|
||||
|
|
@ -82,10 +81,10 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
userId: widget.userId,
|
||||
),
|
||||
),
|
||||
icon: Icon(Icons.adaptive.more_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionHeader(
|
||||
'Recent messages',
|
||||
padding: EdgeInsets.all(10.0),
|
||||
),
|
||||
Expanded(
|
||||
child: Observer(
|
||||
|
|
@ -100,13 +99,15 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaleFactor:
|
||||
widget.chatStore.settings.messageScale),
|
||||
textScaleFactor: widget.chatStore.settings.messageScale,
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: widget.chatStore.settings.fontSize),
|
||||
style: DefaultTextStyle.of(context)
|
||||
.style
|
||||
.copyWith(fontSize: widget.chatStore.settings.fontSize),
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
primary: false,
|
||||
itemCount: userMessages.length,
|
||||
itemBuilder: (context, index) => ChatMessage(
|
||||
ircMessage: userMessages[index],
|
||||
|
|
@ -118,9 +119,8 @@ class _ChatUserModalState extends State<ChatUserModal> {
|
|||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
89
lib/screens/channel/chat/widgets/reply_thread.dart
Normal file
89
lib/screens/channel/chat/widgets/reply_thread.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,8 @@ class _VideoState extends State<Video> with WidgetsBindingObserver {
|
|||
|
||||
@override
|
||||
Future<void> didChangeAppLifecycleState(
|
||||
AppLifecycleState lifecycleState) async {
|
||||
AppLifecycleState lifecycleState,
|
||||
) async {
|
||||
if (Platform.isAndroid &&
|
||||
!await SimplePip.isAutoPipAvailable &&
|
||||
lifecycleState == AppLifecycleState.inactive) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class VideoBar extends StatelessWidget {
|
|||
final Color? titleTextColor;
|
||||
final Color? subtitleTextColor;
|
||||
final FontWeight? subtitleTextWeight;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const VideoBar({
|
||||
Key? key,
|
||||
|
|
@ -20,6 +21,7 @@ class VideoBar extends StatelessWidget {
|
|||
this.titleTextColor,
|
||||
this.subtitleTextColor,
|
||||
this.subtitleTextWeight,
|
||||
this.padding = const EdgeInsets.all(12),
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
|
@ -33,12 +35,14 @@ class VideoBar extends StatelessWidget {
|
|||
: '${streamInfo.userName} (${streamInfo.userLogin})';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
padding: padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ProfilePicture(userLogin: streamInfo.userLogin),
|
||||
const SizedBox(width: 10.0),
|
||||
ProfilePicture(
|
||||
userLogin: streamInfo.userLogin,
|
||||
radius: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
@ -57,7 +61,7 @@ class VideoBar extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
const SizedBox(height: 2),
|
||||
Tooltip(
|
||||
message: 'Title: ${streamInfo.title.trim()}',
|
||||
showDuration: const Duration(seconds: 5),
|
||||
|
|
@ -75,14 +79,13 @@ class VideoBar extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
if (showCategory) ...[
|
||||
const SizedBox(height: 5.0),
|
||||
const SizedBox(height: 2),
|
||||
InkWell(
|
||||
onTap: tappableCategory && streamInfo.gameName.isNotEmpty
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryStreams(
|
||||
categoryName: streamInfo.gameName,
|
||||
categoryId: streamInfo.gameId,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
|||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||
import 'package:frosty/screens/channel/video/video_bar.dart';
|
||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||
import 'package:frosty/widgets/bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/uptime.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
|
|
@ -94,7 +93,8 @@ class VideoOverlay extends StatelessWidget {
|
|||
]);
|
||||
} else {
|
||||
SystemChrome.setPreferredOrientations(
|
||||
[DeviceOrientation.portraitUp]);
|
||||
[DeviceOrientation.portraitUp],
|
||||
);
|
||||
SystemChrome.setPreferredOrientations([]);
|
||||
}
|
||||
},
|
||||
|
|
@ -157,7 +157,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
message: videoStore.paused ? 'Play' : 'Pause',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
iconSize: 50.0,
|
||||
iconSize: 56,
|
||||
icon: Icon(
|
||||
videoStore.paused
|
||||
? Icons.play_arrow_rounded
|
||||
|
|
@ -175,7 +175,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0, bottom: 12.0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Tooltip(
|
||||
|
|
@ -183,15 +183,12 @@ class VideoOverlay extends StatelessWidget {
|
|||
preferBelow: false,
|
||||
child: Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(2.0),
|
||||
child: Icon(
|
||||
const Icon(
|
||||
Icons.circle,
|
||||
color: Colors.red,
|
||||
size: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3.0),
|
||||
const SizedBox(width: 4),
|
||||
Uptime(
|
||||
startTime: streamInfo.startedAt,
|
||||
style: const TextStyle(
|
||||
|
|
@ -202,19 +199,17 @@ class VideoOverlay extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 12),
|
||||
Tooltip(
|
||||
message: 'Viewer count',
|
||||
preferBelow: false,
|
||||
child: GestureDetector(
|
||||
onTap: () => showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => FrostyBottomSheet(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height *
|
||||
0.8,
|
||||
builder: (context) => SizedBox(
|
||||
height:
|
||||
MediaQuery.of(context).size.height * 0.8,
|
||||
child: GestureDetector(
|
||||
onTap: FocusScope.of(context).unfocus,
|
||||
child: ChattersList(
|
||||
|
|
@ -226,7 +221,6 @@ class VideoOverlay extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
|
|
@ -234,10 +228,11 @@ class VideoOverlay extends StatelessWidget {
|
|||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
NumberFormat().format(
|
||||
videoStore.streamInfo?.viewerCount),
|
||||
videoStore.streamInfo?.viewerCount,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
@ -267,7 +262,7 @@ class VideoOverlay extends StatelessWidget {
|
|||
if (orientation == Orientation.landscape) fullScreenButton,
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -39,18 +39,26 @@ abstract class VideoStoreBase with Store {
|
|||
WebViewController.fromPlatformCreationParams(_videoWebViewParams)
|
||||
..setBackgroundColor(Colors.black)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..addJavaScriptChannel('VideoPause', onMessageReceived: (message) {
|
||||
..addJavaScriptChannel(
|
||||
'VideoPause',
|
||||
onMessageReceived: (message) {
|
||||
_paused = true;
|
||||
if (Platform.isAndroid) pip.setIsPlaying(false);
|
||||
})
|
||||
..addJavaScriptChannel('VideoPlaying', onMessageReceived: (message) {
|
||||
},
|
||||
)
|
||||
..addJavaScriptChannel(
|
||||
'VideoPlaying',
|
||||
onMessageReceived: (message) {
|
||||
_paused = false;
|
||||
if (Platform.isAndroid) pip.setIsPlaying(true);
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].muted = false;');
|
||||
'document.getElementsByTagName("video")[0].muted = false;',
|
||||
);
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].volume = 1.0;');
|
||||
})
|
||||
'document.getElementsByTagName("video")[0].volume = 1.0;',
|
||||
);
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageFinished: (_) => initVideo(),
|
||||
|
|
@ -136,9 +144,11 @@ abstract class VideoStoreBase with Store {
|
|||
// Add event listeners to notify the JavaScript channels when the video plays and pauses.
|
||||
try {
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));');
|
||||
'document.getElementsByTagName("video")[0].addEventListener("pause", () => VideoPause.postMessage("video paused"));',
|
||||
);
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));');
|
||||
'document.getElementsByTagName("video")[0].addEventListener("playing", () => VideoPlaying.postMessage("video playing"));',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
|
|
@ -150,7 +160,7 @@ abstract class VideoStoreBase with Store {
|
|||
if (Platform.isIOS) {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final info = await deviceInfo.iosInfo;
|
||||
if (info.model?.toLowerCase().contains('ipad') == true) {
|
||||
if (info.model.toLowerCase().contains('ipad')) {
|
||||
_isIPad = true;
|
||||
} else {
|
||||
_isIPad = false;
|
||||
|
|
@ -181,7 +191,9 @@ abstract class VideoStoreBase with Store {
|
|||
Future<void> updateStreamInfo() async {
|
||||
try {
|
||||
_streamInfo = await twitchApi.getStream(
|
||||
userLogin: userLogin, headers: authStore.headersTwitch);
|
||||
userLogin: userLogin,
|
||||
headers: authStore.headersTwitch,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
|
||||
|
|
@ -227,7 +239,8 @@ abstract class VideoStoreBase with Store {
|
|||
.runJavaScript('document.getElementsByTagName("video")[0].play();');
|
||||
} else {
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].pause();');
|
||||
'document.getElementsByTagName("video")[0].pause();',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
|
|
@ -244,7 +257,8 @@ abstract class VideoStoreBase with Store {
|
|||
pip.enterPipMode(autoEnter: true);
|
||||
} else if (Platform.isIOS) {
|
||||
videoWebViewController.runJavaScript(
|
||||
'document.getElementsByTagName("video")[0].requestPictureInPicture();');
|
||||
'document.getElementsByTagName("video")[0].requestPictureInPicture();',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:frosty/screens/home/top/top.dart';
|
|||
import 'package:frosty/screens/settings/settings.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class Home extends StatefulWidget {
|
||||
|
|
@ -27,6 +28,8 @@ class _HomeState extends State<Home> {
|
|||
Widget build(BuildContext context) {
|
||||
SystemChrome.setPreferredOrientations([]);
|
||||
|
||||
final isLoggedIn = _authStore.isLoggedIn && _authStore.user.details != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: FocusScope.of(context).unfocus,
|
||||
child: Scaffold(
|
||||
|
|
@ -46,7 +49,12 @@ class _HomeState extends State<Home> {
|
|||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
icon: const Icon(Icons.settings_rounded),
|
||||
icon: isLoggedIn
|
||||
? ProfilePicture(
|
||||
userLogin: _authStore.user.details!.login,
|
||||
radius: 16,
|
||||
)
|
||||
: const Icon(Icons.settings_rounded),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -54,7 +62,7 @@ class _HomeState extends State<Home> {
|
|||
Settings(settingsStore: context.read<SettingsStore>()),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
|
|
@ -78,33 +86,29 @@ class _HomeState extends State<Home> {
|
|||
),
|
||||
),
|
||||
bottomNavigationBar: Observer(
|
||||
builder: (_) => BottomNavigationBar(
|
||||
selectedFontSize: 12.0,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),
|
||||
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500),
|
||||
items: [
|
||||
builder: (_) => NavigationBar(
|
||||
destinations: [
|
||||
if (_authStore.isLoggedIn)
|
||||
BottomNavigationBarItem(
|
||||
NavigationDestination(
|
||||
icon: _homeStore.selectedIndex == 0
|
||||
? const Icon(Icons.favorite_rounded)
|
||||
: const Icon(Icons.favorite_border_rounded),
|
||||
label: 'Following',
|
||||
tooltip: 'Followed streams',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.arrow_upward_rounded),
|
||||
label: 'Top',
|
||||
tooltip: 'Top streams and categories',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.search_rounded),
|
||||
label: 'Search',
|
||||
tooltip: 'Search for channels and categories',
|
||||
),
|
||||
],
|
||||
currentIndex: _homeStore.selectedIndex,
|
||||
onTap: _homeStore.handleTap,
|
||||
selectedIndex: _homeStore.selectedIndex,
|
||||
onDestinationSelected: _homeStore.handleTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ abstract class HomeStoreBase with Store {
|
|||
if (index != _selectedIndex) {
|
||||
_selectedIndex = index;
|
||||
} else {
|
||||
const duration = Duration(milliseconds: 500);
|
||||
const duration = Duration(milliseconds: 300);
|
||||
|
||||
// Use different logic if logged in/out since there will be one less tab when logged out.
|
||||
if (authStore.isLoggedIn) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import 'package:frosty/screens/home/search/search_results_channels.dart';
|
|||
import 'package:frosty/screens/home/search/search_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/list_tile.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
|
@ -33,20 +32,17 @@ class _SearchState extends State<Search> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const headerPadding = EdgeInsets.fromLTRB(15.0, 20.0, 15.0, 10.0);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
|
||||
child: Observer(
|
||||
Observer(
|
||||
builder: (context) {
|
||||
return TextField(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
controller: _searchStore.textEditingController,
|
||||
focusNode: _searchStore.textFieldFocusNode,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
hintText: 'Find a channel or category',
|
||||
suffixIcon: _searchStore.textFieldFocusNode.hasFocus ||
|
||||
|
|
@ -66,11 +62,13 @@ class _SearchState extends State<Search> {
|
|||
: null,
|
||||
),
|
||||
onSubmitted: _searchStore.handleQuery,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: widget.scrollController,
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
if (_searchStore.textEditingController.text.isEmpty) {
|
||||
|
|
@ -80,38 +78,42 @@ class _SearchState extends State<Search> {
|
|||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const SectionHeader(
|
||||
'History',
|
||||
padding: headerPadding,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _searchStore.searchHistory.clear,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: widget.scrollController,
|
||||
children: _searchStore.searchHistory
|
||||
.mapIndexed(
|
||||
(index, searchTerm) => FrostyListTile(
|
||||
(index, searchTerm) => ListTile(
|
||||
leading: const Icon(Icons.history_rounded),
|
||||
title: searchTerm,
|
||||
trailing: Tooltip(
|
||||
message: 'Remove',
|
||||
preferBelow: false,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => _searchStore.searchHistory
|
||||
.removeAt(index),
|
||||
),
|
||||
),
|
||||
title: Text(searchTerm),
|
||||
onTap: () {
|
||||
_searchStore.textEditingController.text =
|
||||
searchTerm;
|
||||
_searchStore.handleQuery(searchTerm);
|
||||
_searchStore.textEditingController.selection =
|
||||
TextSelection.fromPosition(TextPosition(
|
||||
_searchStore.textEditingController
|
||||
.selection = TextSelection.fromPosition(
|
||||
TextPosition(
|
||||
offset: _searchStore
|
||||
.textEditingController
|
||||
.text
|
||||
.length));
|
||||
.textEditingController.text.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
@ -127,7 +129,7 @@ class _SearchState extends State<Search> {
|
|||
const SliverToBoxAdapter(
|
||||
child: SectionHeader(
|
||||
'Channels',
|
||||
padding: headerPadding,
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
),
|
||||
),
|
||||
SearchResultsChannels(
|
||||
|
|
@ -137,7 +139,7 @@ class _SearchState extends State<Search> {
|
|||
const SliverToBoxAdapter(
|
||||
child: SectionHeader(
|
||||
'Categories',
|
||||
padding: headerPadding,
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
),
|
||||
),
|
||||
SearchResultsCategories(searchStore: _searchStore),
|
||||
|
|
@ -146,6 +148,7 @@ class _SearchState extends State<Search> {
|
|||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,18 +57,11 @@ class SearchResultsCategories extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
return SliverGrid.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 20,
|
||||
children: categories.data
|
||||
.map(
|
||||
(category) => GridTile(
|
||||
child: CategoryCard(
|
||||
category: category,
|
||||
return SliverList.builder(
|
||||
itemCount: categories.data.length,
|
||||
itemBuilder: (context, index) => CategoryCard(
|
||||
category: categories.data[index],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import 'package:frosty/models/channel.dart';
|
|||
import 'package:frosty/screens/channel/channel.dart';
|
||||
import 'package:frosty/screens/home/search/search_store.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/animate_scale.dart';
|
||||
import 'package:frosty/widgets/block_report_modal.dart';
|
||||
import 'package:frosty/widgets/list_tile.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:frosty/widgets/uptime.dart';
|
||||
|
|
@ -46,8 +44,10 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
);
|
||||
} catch (error) {
|
||||
final snackBar = SnackBar(
|
||||
content: AlertMessage(message: error.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: AlertMessage(
|
||||
message: error.toString(),
|
||||
centered: false,
|
||||
),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
|
|
@ -78,7 +78,8 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
final results = (future.result as List<ChannelQuery>).where(
|
||||
(channel) => !widget.searchStore.authStore.user.blockedUsers
|
||||
.map((blockedUser) => blockedUser.userId)
|
||||
.contains(channel.id));
|
||||
.contains(channel.id),
|
||||
);
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate.fixed(
|
||||
|
|
@ -90,7 +91,7 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
? channel.displayName
|
||||
: '${channel.displayName} (${channel.broadcasterLogin})';
|
||||
|
||||
return AnimateScale(
|
||||
return InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -105,7 +106,6 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
HapticFeedback.lightImpact();
|
||||
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
context: context,
|
||||
builder: (context) => BlockReportModal(
|
||||
authStore: widget.searchStore.authStore,
|
||||
|
|
@ -115,11 +115,12 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
),
|
||||
);
|
||||
},
|
||||
child: FrostyListTile(
|
||||
isThreeLine: false,
|
||||
title: displayName,
|
||||
child: ListTile(
|
||||
title: Text(displayName),
|
||||
leading: ProfilePicture(
|
||||
userLogin: channel.broadcasterLogin),
|
||||
userLogin: channel.broadcasterLogin,
|
||||
radius: 16,
|
||||
),
|
||||
subtitle: channel.isLive
|
||||
? Row(
|
||||
children: [
|
||||
|
|
@ -137,11 +138,11 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
|
|||
);
|
||||
},
|
||||
),
|
||||
FrostyListTile(
|
||||
title: 'Go to channel "${widget.query}"',
|
||||
ListTile(
|
||||
title: Text('Go to channel "${widget.query}"'),
|
||||
onTap: () => _handleSearch(context, widget.query),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -90,9 +90,13 @@ abstract class SearchStoreBase with Store {
|
|||
/// This is used for channels that may not show up in the search results.
|
||||
Future<Channel> searchChannel(String query) async {
|
||||
final user = await twitchApi.getUser(
|
||||
userLogin: query, headers: authStore.headersTwitch);
|
||||
userLogin: query,
|
||||
headers: authStore.headersTwitch,
|
||||
);
|
||||
return await twitchApi.getChannel(
|
||||
userId: user.id, headers: authStore.headersTwitch);
|
||||
userId: user.id,
|
||||
headers: authStore.headersTwitch,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@ import 'dart:math';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/channel/channel.dart';
|
||||
import 'package:frosty/screens/channel/video/video_bar.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/animate_scale.dart';
|
||||
import 'package:frosty/widgets/block_report_modal.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
|
|
@ -45,7 +43,7 @@ class LargeStreamCard extends StatelessWidget {
|
|||
final thumbnailHeight = min((thumbnailWidth * (9 / 16)).toInt(), 1080);
|
||||
|
||||
final thumbnail = ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomLeft,
|
||||
children: [
|
||||
|
|
@ -66,16 +64,19 @@ class LargeStreamCard extends StatelessWidget {
|
|||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
|
||||
'-{width}x{height}',
|
||||
'-${thumbnailWidth}x$thumbnailHeight') +
|
||||
'-${thumbnailWidth}x$thumbnailHeight',
|
||||
) +
|
||||
cacheUrlExtension,
|
||||
placeholder: (context, url) => const ColoredBox(
|
||||
color: lightGray, child: LoadingIndicator()),
|
||||
placeholder: (context, url) => ColoredBox(
|
||||
color: Colors.grey.shade900,
|
||||
child: const LoadingIndicator(),
|
||||
),
|
||||
useOldImageOnUrlChange: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0, bottom: 12.0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Tooltip(
|
||||
|
|
@ -83,15 +84,12 @@ class LargeStreamCard extends StatelessWidget {
|
|||
preferBelow: false,
|
||||
child: Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(2.0),
|
||||
child: Icon(
|
||||
const Icon(
|
||||
Icons.circle,
|
||||
color: Colors.red,
|
||||
size: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3.0),
|
||||
const SizedBox(width: 4),
|
||||
Uptime(
|
||||
startTime: streamInfo.startedAt,
|
||||
style: const TextStyle(
|
||||
|
|
@ -102,7 +100,7 @@ class LargeStreamCard extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10.0),
|
||||
const SizedBox(width: 12),
|
||||
Tooltip(
|
||||
message: 'Viewer count',
|
||||
preferBelow: false,
|
||||
|
|
@ -113,7 +111,7 @@ class LargeStreamCard extends StatelessWidget {
|
|||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
NumberFormat().format(streamInfo.viewerCount),
|
||||
style: const TextStyle(
|
||||
|
|
@ -123,7 +121,7 @@ class LargeStreamCard extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -135,7 +133,7 @@ class LargeStreamCard extends StatelessWidget {
|
|||
? streamInfo.userName
|
||||
: '${streamInfo.userName} (${streamInfo.userLogin})';
|
||||
|
||||
return AnimateScale(
|
||||
return InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -150,7 +148,6 @@ class LargeStreamCard extends StatelessWidget {
|
|||
HapticFeedback.mediumImpact();
|
||||
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
context: context,
|
||||
builder: (context) => BlockReportModal(
|
||||
authStore: context.read<AuthStore>(),
|
||||
|
|
@ -162,13 +159,16 @@ class LargeStreamCard extends StatelessWidget {
|
|||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: showThumbnail ? 10.0 : 5.0, horizontal: 15.0),
|
||||
vertical: showThumbnail ? 12 : 4,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showThumbnail) thumbnail,
|
||||
VideoBar(
|
||||
streamInfo: streamInfo,
|
||||
showCategory: showCategory,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@ import 'dart:math';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/channel/channel.dart';
|
||||
import 'package:frosty/screens/home/top/categories/category_streams.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/animate_scale.dart';
|
||||
import 'package:frosty/widgets/block_report_modal.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
|
|
@ -50,10 +48,14 @@ class StreamCard extends StatelessWidget {
|
|||
aspectRatio: 16 / 9,
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: streamInfo.thumbnailUrl.replaceFirst(
|
||||
'-{width}x{height}', '-${thumbnailWidth}x$thumbnailHeight') +
|
||||
'-{width}x{height}',
|
||||
'-${thumbnailWidth}x$thumbnailHeight',
|
||||
) +
|
||||
cacheUrlExtension,
|
||||
placeholder: (context, url) =>
|
||||
const ColoredBox(color: lightGray, child: LoadingIndicator()),
|
||||
placeholder: (context, url) => ColoredBox(
|
||||
color: Colors.grey.shade900,
|
||||
child: const LoadingIndicator(),
|
||||
),
|
||||
useOldImageOnUrlChange: true,
|
||||
),
|
||||
);
|
||||
|
|
@ -67,20 +69,20 @@ class StreamCard extends StatelessWidget {
|
|||
final fontColor = DefaultTextStyle.of(context).style.color;
|
||||
|
||||
final imageSection = ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(5.0)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.bottomEnd,
|
||||
children: [
|
||||
thumbnail,
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color.fromRGBO(0, 0, 0, 0.5),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(3.0),
|
||||
Radius.circular(6),
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.all(2.0),
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: Uptime(
|
||||
startTime: streamInfo.startedAt,
|
||||
style: const TextStyle(
|
||||
|
|
@ -89,12 +91,13 @@ class StreamCard extends StatelessWidget {
|
|||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
));
|
||||
),
|
||||
);
|
||||
|
||||
final streamInfoSection = Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -102,9 +105,9 @@ class StreamCard extends StatelessWidget {
|
|||
children: [
|
||||
ProfilePicture(
|
||||
userLogin: streamInfo.userLogin,
|
||||
radius: 10.0,
|
||||
radius: 10,
|
||||
),
|
||||
const SizedBox(width: 5.0),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Tooltip(
|
||||
message: 'Streamer: $streamerName',
|
||||
|
|
@ -113,7 +116,7 @@ class StreamCard extends StatelessWidget {
|
|||
streamerName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fontColor,
|
||||
),
|
||||
|
|
@ -122,11 +125,10 @@ class StreamCard extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
const SizedBox(height: 2),
|
||||
Tooltip(
|
||||
message: 'Title: ${streamInfo.title.trim()}',
|
||||
preferBelow: false,
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: Text(
|
||||
streamInfo.title.trim(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -136,7 +138,7 @@ class StreamCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
const SizedBox(height: 2),
|
||||
if (showCategory) ...[
|
||||
InkWell(
|
||||
onTap: streamInfo.gameName.isNotEmpty
|
||||
|
|
@ -144,7 +146,6 @@ class StreamCard extends StatelessWidget {
|
|||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryStreams(
|
||||
categoryName: streamInfo.gameName,
|
||||
categoryId: streamInfo.gameId,
|
||||
),
|
||||
),
|
||||
|
|
@ -166,7 +167,7 @@ class StreamCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
Text(
|
||||
'${NumberFormat().format(streamInfo.viewerCount)} viewers',
|
||||
|
|
@ -179,7 +180,7 @@ class StreamCard extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
|
||||
return AnimateScale(
|
||||
return InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -194,7 +195,6 @@ class StreamCard extends StatelessWidget {
|
|||
HapticFeedback.mediumImpact();
|
||||
|
||||
showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
context: context,
|
||||
builder: (context) => BlockReportModal(
|
||||
authStore: context.read<AuthStore>(),
|
||||
|
|
@ -206,7 +206,9 @@ class StreamCard extends StatelessWidget {
|
|||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 10.0, horizontal: showThumbnail ? 15.0 : 5.0),
|
||||
vertical: 8,
|
||||
horizontal: showThumbnail ? 16 : 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (showThumbnail)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/models/category.dart';
|
||||
import 'package:frosty/models/stream.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
|
@ -45,6 +46,9 @@ abstract class ListStoreBase with Store {
|
|||
@readonly
|
||||
var _allStreams = ObservableList<StreamTwitch>();
|
||||
|
||||
@readonly
|
||||
CategoryTwitch? _categoryDetails;
|
||||
|
||||
/// Whether or not the scroll to top button is visible.
|
||||
@observable
|
||||
var showJumpButton = false;
|
||||
|
|
@ -52,9 +56,11 @@ abstract class ListStoreBase with Store {
|
|||
/// The list of the fetched streams with blocked users filtered out.
|
||||
@computed
|
||||
ObservableList<StreamTwitch> get streams => _allStreams
|
||||
.where((streamInfo) => !authStore.user.blockedUsers
|
||||
.where(
|
||||
(streamInfo) => !authStore.user.blockedUsers
|
||||
.map((blockedUser) => blockedUser.userId)
|
||||
.contains(streamInfo.userId))
|
||||
.contains(streamInfo.userId),
|
||||
)
|
||||
.toList()
|
||||
.asObservable();
|
||||
|
||||
|
|
@ -81,6 +87,10 @@ abstract class ListStoreBase with Store {
|
|||
}
|
||||
|
||||
getStreams();
|
||||
|
||||
if (listType == ListType.category && categoryId != null) {
|
||||
_getCategoryDetails();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the streams based on the type and current cursor.
|
||||
|
|
@ -138,6 +148,20 @@ abstract class ListStoreBase with Store {
|
|||
return getStreams();
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> _getCategoryDetails() async {
|
||||
_isLoading = true;
|
||||
|
||||
final categoryDetails = await twitchApi.getCategory(
|
||||
headers: authStore.headersTwitch,
|
||||
gameId: categoryId!,
|
||||
);
|
||||
|
||||
_categoryDetails = categoryDetails.data.first;
|
||||
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
/// Checks the last time the streams were refreshed and updates them if it has been more than 5 minutes.
|
||||
void checkLastTimeRefreshedAndUpdate() {
|
||||
final now = DateTime.now();
|
||||
|
|
|
|||
|
|
@ -59,6 +59,24 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$_categoryDetailsAtom =
|
||||
Atom(name: 'ListStoreBase._categoryDetails', context: context);
|
||||
|
||||
CategoryTwitch? get categoryDetails {
|
||||
_$_categoryDetailsAtom.reportRead();
|
||||
return super._categoryDetails;
|
||||
}
|
||||
|
||||
@override
|
||||
CategoryTwitch? get _categoryDetails => categoryDetails;
|
||||
|
||||
@override
|
||||
set _categoryDetails(CategoryTwitch? value) {
|
||||
_$_categoryDetailsAtom.reportWrite(value, super._categoryDetails, () {
|
||||
super._categoryDetails = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$showJumpButtonAtom =
|
||||
Atom(name: 'ListStoreBase.showJumpButton', context: context);
|
||||
|
||||
|
|
@ -101,6 +119,15 @@ mixin _$ListStore on ListStoreBase, Store {
|
|||
return _$getStreamsAsyncAction.run(() => super.getStreams());
|
||||
}
|
||||
|
||||
late final _$_getCategoryDetailsAsyncAction =
|
||||
AsyncAction('ListStoreBase._getCategoryDetails', context: context);
|
||||
|
||||
@override
|
||||
Future<void> _getCategoryDetails() {
|
||||
return _$_getCategoryDetailsAsyncAction
|
||||
.run(() => super._getCategoryDetails());
|
||||
}
|
||||
|
||||
late final _$ListStoreBaseActionController =
|
||||
ActionController(name: 'ListStoreBase', context: context);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import 'package:frosty/apis/twitch_api.dart';
|
|||
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_card.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||
import 'package:frosty/screens/home/top/categories/category_card.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/scroll_to_top_button.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// A widget that displays a list of followed or top streams based on the provided [listType].
|
||||
|
|
@ -17,13 +19,19 @@ class StreamsList extends StatefulWidget {
|
|||
/// The type of list to display.
|
||||
final ListType listType;
|
||||
|
||||
final String? categoryId;
|
||||
|
||||
/// The scroll controller to use for scroll to top functionality.
|
||||
final ScrollController scrollController;
|
||||
final ScrollController? scrollController;
|
||||
|
||||
final bool showJumpButton;
|
||||
|
||||
const StreamsList({
|
||||
Key? key,
|
||||
required this.listType,
|
||||
required this.scrollController,
|
||||
this.categoryId,
|
||||
this.scrollController,
|
||||
this.showJumpButton = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
|
@ -36,6 +44,9 @@ class _StreamsListState extends State<StreamsList>
|
|||
authStore: context.read<AuthStore>(),
|
||||
twitchApi: context.read<TwitchApi>(),
|
||||
listType: widget.listType,
|
||||
categoryId: widget.categoryId,
|
||||
scrollController: widget.scrollController ??
|
||||
(widget.showJumpButton ? ScrollController() : null),
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -62,7 +73,7 @@ class _StreamsListState extends State<StreamsList>
|
|||
|
||||
_listStore.checkLastTimeRefreshedAndUpdate();
|
||||
|
||||
return RefreshIndicator(
|
||||
return RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
|
|
@ -70,8 +81,10 @@ class _StreamsListState extends State<StreamsList>
|
|||
|
||||
if (_listStore.error != null) {
|
||||
final snackBar = SnackBar(
|
||||
content: AlertMessage(message: _listStore.error!),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: AlertMessage(
|
||||
message: _listStore.error!,
|
||||
centered: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
@ -94,7 +107,8 @@ class _StreamsListState extends State<StreamsList>
|
|||
statusWidget = AlertMessage(
|
||||
message: widget.listType == ListType.followed
|
||||
? 'No followed streams'
|
||||
: 'No top streams');
|
||||
: 'No top streams',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,14 +119,28 @@ class _StreamsListState extends State<StreamsList>
|
|||
child: Center(
|
||||
child: statusWidget,
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
if (widget.categoryId != null &&
|
||||
_listStore.categoryDetails != null)
|
||||
CategoryCard(
|
||||
category: _listStore.categoryDetails!,
|
||||
isTappable: false,
|
||||
),
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: _listStore.scrollController,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
controller: widget.scrollController,
|
||||
controller: _listStore.scrollController,
|
||||
itemCount: _listStore.streams.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index > _listStore.streams.length - 10 &&
|
||||
|
|
@ -125,19 +153,46 @@ class _StreamsListState extends State<StreamsList>
|
|||
builder: (context) =>
|
||||
context.read<SettingsStore>().largeStreamCard
|
||||
? LargeStreamCard(
|
||||
key: ValueKey(_listStore.streams[index].userId),
|
||||
key: ValueKey(
|
||||
_listStore.streams[index].userId,
|
||||
),
|
||||
streamInfo: _listStore.streams[index],
|
||||
showThumbnail:
|
||||
context.read<SettingsStore>().showThumbnails,
|
||||
showThumbnail: context
|
||||
.read<SettingsStore>()
|
||||
.showThumbnails,
|
||||
showCategory: widget.categoryId == null,
|
||||
)
|
||||
: StreamCard(
|
||||
key: ValueKey(_listStore.streams[index].userId),
|
||||
key: ValueKey(
|
||||
_listStore.streams[index].userId,
|
||||
),
|
||||
streamInfo: _listStore.streams[index],
|
||||
showThumbnail:
|
||||
context.read<SettingsStore>().showThumbnails,
|
||||
showThumbnail: context
|
||||
.read<SettingsStore>()
|
||||
.showThumbnails,
|
||||
showCategory: widget.categoryId == null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.showJumpButton)
|
||||
Observer(
|
||||
builder: (context) => AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: _listStore.showJumpButton
|
||||
? ScrollToTopButton(
|
||||
scrollController: _listStore.scrollController!,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -52,15 +52,17 @@ class _CategoriesState extends State<Categories>
|
|||
|
||||
_categoriesStore.checkLastTimeRefreshedAndUpdate();
|
||||
|
||||
return RefreshIndicator(
|
||||
return RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
await _categoriesStore.refreshCategories();
|
||||
|
||||
if (_categoriesStore.error != null) {
|
||||
final snackBar = SnackBar(
|
||||
content: AlertMessage(message: _categoriesStore.error!),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: AlertMessage(
|
||||
message: _categoriesStore.error!,
|
||||
centered: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
@ -91,17 +93,16 @@ class _CategoriesState extends State<Categories>
|
|||
child: Center(
|
||||
child: statusWidget,
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
return Scrollbar(
|
||||
controller: widget.scrollController,
|
||||
child: ListView.builder(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
controller: widget.scrollController,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
),
|
||||
itemCount: _categoriesStore.categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index > _categoriesStore.categories.length - 10 &&
|
||||
|
|
@ -113,6 +114,7 @@ class _CategoriesState extends State<Categories>
|
|||
category: _categoriesStore.categories[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/models/category.dart';
|
||||
import 'package:frosty/screens/home/top/categories/category_streams.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
|
|
@ -8,10 +7,12 @@ import 'package:frosty/widgets/loading_indicator.dart';
|
|||
/// A tappable card widget that displays a category's box art and name under.
|
||||
class CategoryCard extends StatelessWidget {
|
||||
final CategoryTwitch category;
|
||||
final bool isTappable;
|
||||
|
||||
const CategoryCard({
|
||||
Key? key,
|
||||
required this.category,
|
||||
this.isTappable = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
|
@ -19,47 +20,54 @@ class CategoryCard extends StatelessWidget {
|
|||
// Calculate the dimmensions of the box art based on the current dimmensions of the screen.
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final artWidth = (size.width * pixelRatio) ~/ 3;
|
||||
final artWidth = (size.width * pixelRatio) ~/ 5;
|
||||
final artHeight = (artWidth * (4 / 3)).toInt();
|
||||
|
||||
return InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
onTap: isTappable
|
||||
? () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryStreams(
|
||||
categoryName: category.name,
|
||||
categoryId: category.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 20.0),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: FrostyCachedNetworkImage(
|
||||
imageUrl: category.boxArtUrl.replaceRange(
|
||||
category.boxArtUrl.lastIndexOf('-') + 1,
|
||||
null,
|
||||
'${artWidth}x$artHeight.jpg'),
|
||||
placeholder: (context, url) => const ColoredBox(
|
||||
color: lightGray, child: LoadingIndicator()),
|
||||
'${artWidth}x$artHeight.jpg',
|
||||
),
|
||||
placeholder: (context, url) => ColoredBox(
|
||||
color: Colors.grey.shade900,
|
||||
child: const LoadingIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5.0),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Tooltip(
|
||||
message: category.name,
|
||||
preferBelow: false,
|
||||
padding: const EdgeInsets.all(10.0),
|
||||
child: Text(
|
||||
category.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,218 +1,29 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/models/category.dart';
|
||||
import 'package:frosty/screens/home/stream_list/large_stream_card.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_card.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
import 'package:frosty/widgets/loading_indicator.dart';
|
||||
import 'package:frosty/widgets/scroll_to_top_button.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
||||
import 'package:frosty/widgets/app_bar.dart';
|
||||
|
||||
/// A widget that displays a list of streams under the provided [categoryId].
|
||||
/// This differs from the normal [StreamsList] in that it uses slivers to display the box art on top.
|
||||
class CategoryStreams extends StatefulWidget {
|
||||
/// The category name, used for the header on the box art sliver.
|
||||
final String categoryName;
|
||||
|
||||
class CategoryStreams extends StatelessWidget {
|
||||
/// The category id, used for fetching the relevant streams in the [ListStore].
|
||||
final String categoryId;
|
||||
|
||||
const CategoryStreams({
|
||||
Key? key,
|
||||
required this.categoryName,
|
||||
required this.categoryId,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CategoryStreams> createState() => _CategoryStreamsState();
|
||||
}
|
||||
|
||||
class _CategoryStreamsState extends State<CategoryStreams> {
|
||||
late final _listStore = ListStore(
|
||||
authStore: context.read<AuthStore>(),
|
||||
twitchApi: context.read<TwitchApi>(),
|
||||
listType: ListType.category,
|
||||
categoryId: widget.categoryId,
|
||||
scrollController: ScrollController(),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Calculate the dimensions of the box art based on the screen width.
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final artWidth = (size.width * pixelRatio).toInt();
|
||||
final artHeight = (artWidth * (4 / 3)).toInt();
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
await _listStore.refreshStreams();
|
||||
|
||||
if (_listStore.error != null) {
|
||||
final snackBar = SnackBar(
|
||||
content: AlertMessage(message: _listStore.error!),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
},
|
||||
child: Observer(
|
||||
builder: (context) {
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.bottomCenter,
|
||||
children: [
|
||||
CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics()),
|
||||
controller: _listStore.scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.adaptive.arrow_back_rounded),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
appBar: const FrostyAppBar(
|
||||
title: SizedBox(),
|
||||
),
|
||||
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,
|
||||
body: StreamsList(
|
||||
listType: ListType.category,
|
||||
categoryId: categoryId,
|
||||
showJumpButton: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/screens/home/home_store.dart';
|
||||
import 'package:frosty/screens/home/stream_list/stream_list_store.dart';
|
||||
import 'package:frosty/screens/home/stream_list/streams_list.dart';
|
||||
|
|
@ -24,8 +23,9 @@ class _TopSectionState extends State<TopSection>
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabBarController.addListener(() =>
|
||||
widget.homeStore.topSectionCurrentIndex = _tabBarController.index);
|
||||
_tabBarController.addListener(
|
||||
() => widget.homeStore.topSectionCurrentIndex = _tabBarController.index,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -34,15 +34,12 @@ class _TopSectionState extends State<TopSection>
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
controller: _tabBarController,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
indicatorColor: purple,
|
||||
tabs: const [
|
||||
Tab(child: Text('Streams')),
|
||||
Tab(
|
||||
child: Text('Streams'),
|
||||
),
|
||||
Tab(child: Text('Categories')),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class OnboardingScaffold extends StatelessWidget {
|
||||
|
|
@ -61,7 +60,7 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
'assets/icons/logo.svg',
|
||||
height: 80,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Text(
|
||||
header,
|
||||
|
|
@ -73,7 +72,7 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 24),
|
||||
Opacity(
|
||||
opacity: 0.8,
|
||||
child: Text(
|
||||
|
|
@ -89,14 +88,15 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: content == null ? 20.0 : 0.0),
|
||||
vertical: content == null ? 20 : 0,
|
||||
),
|
||||
child: content ?? const SizedBox(),
|
||||
),
|
||||
),
|
||||
if (disclaimer != null)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 5.0),
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Text(
|
||||
|
|
@ -107,9 +107,9 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => isLast
|
||||
? Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
|
|
@ -122,16 +122,15 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
builder: (context) => route,
|
||||
),
|
||||
),
|
||||
icon: buttonIcon,
|
||||
child: Text(buttonText ?? 'Next'),
|
||||
icon: buttonIcon ?? const SizedBox(),
|
||||
label: Text(buttonText ?? 'Next'),
|
||||
),
|
||||
),
|
||||
if (skipRoute != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 10),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
color: Colors.grey,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -140,7 +139,7 @@ class OnboardingScaffold extends StatelessWidget {
|
|||
),
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class OnboardingSetup extends StatelessWidget {
|
|||
return OnboardingScaffold(
|
||||
header: 'Setup',
|
||||
subtitle:
|
||||
'Let\'s tweak some settings before you get started. You can always change these later.',
|
||||
'Let\'s tweak some settings before you get started. You can always change these and more later.',
|
||||
content: Observer(
|
||||
builder: (context) {
|
||||
return ListView(
|
||||
|
|
@ -35,7 +35,8 @@ class OnboardingSetup extends StatelessWidget {
|
|||
SettingsListSwitch(
|
||||
title: 'Share crash logs and analytics',
|
||||
subtitle: const Text(
|
||||
'Help improve Frosty by sending anonymous crash logs and analytics through Firebase.'),
|
||||
'Help improve Frosty by sending anonymous crash logs and analytics through Firebase.',
|
||||
),
|
||||
value: settingsStore.shareCrashLogsAndAnalytics,
|
||||
onChanged: (newValue) {
|
||||
settingsStore.shareCrashLogsAndAnalytics = newValue;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class OnboardingWelcome extends StatelessWidget {
|
|||
const text = [
|
||||
'Check out the GitHub repo at the top-right of the settings page to explore Frosty\'s source code, report bugs, make feature requests, and more.',
|
||||
'Links to the full changelog and FAQ are in the settings page under "Other".',
|
||||
'Don\'t forget to leave a rating and/or review on the app store!'
|
||||
'Don\'t forget to leave a rating and/or review on the app store!',
|
||||
];
|
||||
|
||||
return OnboardingScaffold(
|
||||
|
|
@ -20,10 +20,11 @@ class OnboardingWelcome extends StatelessWidget {
|
|||
content: Opacity(
|
||||
opacity: 0.8,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: text
|
||||
.mapIndexed((index, sentence) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0),
|
||||
.mapIndexed(
|
||||
(index, sentence) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListTile(
|
||||
leading: Text(
|
||||
'${index + 1}.',
|
||||
|
|
@ -34,7 +35,8 @@ class OnboardingWelcome extends StatelessWidget {
|
|||
),
|
||||
title: Text(sentence),
|
||||
),
|
||||
))
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ import 'package:flutter/material.dart';
|
|||
import 'package:frosty/screens/settings/account/blocked_users.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/dialog.dart';
|
||||
import 'package:frosty/widgets/list_tile.dart';
|
||||
|
||||
class AccountOptions extends StatelessWidget {
|
||||
final AuthStore authStore;
|
||||
|
|
@ -14,11 +11,15 @@ class AccountOptions extends StatelessWidget {
|
|||
Future<void> _showLogoutDialog(BuildContext context) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Log Out',
|
||||
message: 'Are you sure you want to log out?',
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: const Text('Log out'),
|
||||
content: const Text('Are you sure you want to log out?'),
|
||||
actions: [
|
||||
Button(
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
authStore.logout();
|
||||
Navigator.pop(context);
|
||||
|
|
@ -26,11 +27,6 @@ class AccountOptions extends StatelessWidget {
|
|||
},
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
Button(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
color: Colors.grey,
|
||||
child: const Text('Cancel'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -39,20 +35,21 @@ class AccountOptions extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
children: [
|
||||
SettingsTileRoute(
|
||||
leading: const Icon(Icons.block_rounded),
|
||||
title: 'Blocked',
|
||||
title: 'Blocked users',
|
||||
child: BlockedUsers(
|
||||
authStore: authStore,
|
||||
),
|
||||
),
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout_rounded),
|
||||
title: 'Log out',
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
title: const Text('Log out'),
|
||||
onTap: () => _showLogoutDialog(context),
|
||||
)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import 'package:flutter_mobx/flutter_mobx.dart';
|
|||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/list_tile.dart';
|
||||
|
||||
class BlockedUsers extends StatelessWidget {
|
||||
final AuthStore authStore;
|
||||
|
|
@ -17,7 +15,7 @@ class BlockedUsers extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
return RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
|
|
@ -41,10 +39,12 @@ class BlockedUsers extends StatelessWidget {
|
|||
? blockedUser.displayName
|
||||
: '${blockedUser.displayName} (${blockedUser.userLogin})';
|
||||
|
||||
return FrostyListTile(
|
||||
title: displayName,
|
||||
trailing: Button(
|
||||
color: Colors.red,
|
||||
return ListTile(
|
||||
title: Text(displayName),
|
||||
trailing: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
onPressed: () => authStore.showBlockDialog(
|
||||
context,
|
||||
targetUser: displayName,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||
import 'package:frosty/screens/settings/account/account_options.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_tile_route.dart';
|
||||
import 'package:frosty/widgets/app_bar.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/dialog.dart';
|
||||
import 'package:frosty/widgets/list_tile.dart';
|
||||
import 'package:frosty/widgets/profile_picture.dart';
|
||||
import 'package:simple_icons/simple_icons.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
class ProfileCard extends StatelessWidget {
|
||||
|
|
@ -16,17 +12,54 @@ class ProfileCard extends StatelessWidget {
|
|||
|
||||
const ProfileCard({Key? key, required this.authStore}) : super(key: key);
|
||||
|
||||
Future<void> _showLoginDialog(BuildContext context) {
|
||||
return showDialog(
|
||||
Future<void> _showAccountOptionsModalBottomSheet(BuildContext context) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Log In',
|
||||
content: Column(
|
||||
children: [
|
||||
Button(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 30.0, vertical: 15.0),
|
||||
onPressed: () => Navigator.push(
|
||||
builder: (context) {
|
||||
return AccountOptions(authStore: authStore);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
if (authStore.error != null) {
|
||||
return ListTile(
|
||||
leading: const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: Colors.red,
|
||||
),
|
||||
title: const Text('Failed to connect'),
|
||||
trailing: FilledButton.tonal(
|
||||
onPressed: authStore.init,
|
||||
child: const Text('Reconnect'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (authStore.isLoggedIn && authStore.user.details != null) {
|
||||
return ListTile(
|
||||
leading: ProfilePicture(
|
||||
userLogin: authStore.user.details!.login,
|
||||
radius: 12,
|
||||
),
|
||||
title: Text(authStore.user.details!.displayName),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => _showAccountOptionsModalBottomSheet(context),
|
||||
);
|
||||
}
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.no_accounts_rounded),
|
||||
title: const Text('Anonymous'),
|
||||
subtitle: const Text(
|
||||
'Log in to enable the ability to chat, view followed streams, and more.',
|
||||
),
|
||||
trailing: const SizedBox(
|
||||
height: double.infinity,
|
||||
child: Icon(Icons.chevron_right_rounded),
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
|
|
@ -41,79 +74,14 @@ class ProfileCard extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
),
|
||||
icon: const Icon(SimpleIcons.twitch),
|
||||
child: const Text(
|
||||
'Connect with Twitch',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20.0),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Or',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20.0),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Token',
|
||||
),
|
||||
onSubmitted: (token) {
|
||||
authStore.login(token: token);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
onLongPress: () async {
|
||||
final clipboardText =
|
||||
(await Clipboard.getData(Clipboard.kTextPlain))?.text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Observer(
|
||||
builder: (context) {
|
||||
if (authStore.error != null) {
|
||||
return FrostyListTile(
|
||||
leading: const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: Colors.red,
|
||||
),
|
||||
title: 'Failed to connect',
|
||||
trailing: Button(
|
||||
onPressed: authStore.init,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15.0),
|
||||
child: const Text('Reconnect'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (authStore.isLoggedIn && authStore.user.details != null) {
|
||||
return SettingsTileRoute(
|
||||
leading: ProfilePicture(
|
||||
userLogin: authStore.user.details!.login,
|
||||
radius: 12,
|
||||
),
|
||||
title: authStore.user.details!.displayName,
|
||||
child: AccountOptions(authStore: authStore),
|
||||
);
|
||||
}
|
||||
return FrostyListTile(
|
||||
leading: const Icon(Icons.no_accounts_rounded),
|
||||
title: 'Anonymous',
|
||||
subtitle: const Text(
|
||||
'Tap to log in and enable the ability to chat, view followed streams, and more.'),
|
||||
trailing: const SizedBox(
|
||||
height: double.infinity,
|
||||
child: Icon(Icons.chevron_right_rounded),
|
||||
),
|
||||
onTap: () => _showLoginDialog(context),
|
||||
if (clipboardText == null) return;
|
||||
|
||||
authStore.login(token: clipboardText);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
children: [
|
||||
const SectionHeader('Message sizing'),
|
||||
ExpansionTile(
|
||||
title: const Text('Preview',
|
||||
style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
title: const Text(
|
||||
'Preview',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
|
|
@ -122,7 +124,7 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px',
|
||||
value: settingsStore.messageSpacing,
|
||||
max: 30.0,
|
||||
divisions: 6,
|
||||
divisions: 15,
|
||||
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
|
||||
),
|
||||
SettingsListSlider(
|
||||
|
|
@ -134,18 +136,20 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
divisions: 15,
|
||||
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
||||
),
|
||||
const SectionHeader('Message appearance'),
|
||||
const SectionHeader('Message appearance', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Use readable name colors',
|
||||
subtitle: const Text(
|
||||
'Adjusts the lightness value of overly bright and dark names.'),
|
||||
'Adjusts the lightness value of overly bright and dark names.',
|
||||
),
|
||||
value: settingsStore.useReadableColors,
|
||||
onChanged: (newValue) => settingsStore.useReadableColors = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Show deleted messages',
|
||||
subtitle: const Text(
|
||||
'Restores the original message of deleted messages.'),
|
||||
'Restores the original message of deleted messages.',
|
||||
),
|
||||
value: settingsStore.showDeletedMessages,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.showDeletedMessages = newValue,
|
||||
|
|
@ -163,7 +167,7 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
onChanged: (newValue) => settingsStore.timestampType =
|
||||
TimestampType.values[timestampNames.indexOf(newValue)],
|
||||
),
|
||||
const SectionHeader('Delay'),
|
||||
const SectionHeader('Delay', showDivider: true),
|
||||
SettingsListSlider(
|
||||
title: 'Message delay',
|
||||
trailing: '${settingsStore.chatDelay.toInt()}s',
|
||||
|
|
@ -174,7 +178,7 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
divisions: 30,
|
||||
onChanged: (newValue) => settingsStore.chatDelay = newValue,
|
||||
),
|
||||
const SectionHeader('Alerts'),
|
||||
const SectionHeader('Alerts', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Highlight first time chatters',
|
||||
value: settingsStore.highlightFirstTimeChatter,
|
||||
|
|
@ -184,11 +188,12 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
SettingsListSwitch(
|
||||
title: 'Show notices',
|
||||
subtitle: const Text(
|
||||
'Shows notices such as subs and re-subs, announcements, and raids.'),
|
||||
'Shows notices such as subs and re-subs, announcements, and raids.',
|
||||
),
|
||||
value: settingsStore.showUserNotices,
|
||||
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
|
||||
),
|
||||
const SectionHeader('Layout'),
|
||||
const SectionHeader('Layout', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Show bottom bar',
|
||||
value: settingsStore.showBottomBar,
|
||||
|
|
@ -197,7 +202,8 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
SettingsListSwitch(
|
||||
title: 'Move emote menu button left',
|
||||
subtitle: const Text(
|
||||
'Places the emote menu button on the left side to avoid accidental presses.'),
|
||||
'Places the emote menu button on the left side to avoid accidental presses.',
|
||||
),
|
||||
value: settingsStore.emoteMenuButtonOnLeft,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.emoteMenuButtonOnLeft = newValue,
|
||||
|
|
@ -208,7 +214,7 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
onChanged: (newValue) =>
|
||||
settingsStore.chatNotificationsOnBottom = newValue,
|
||||
),
|
||||
const SectionHeader('Landscape mode'),
|
||||
const SectionHeader('Landscape mode', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Move chat left',
|
||||
value: settingsStore.landscapeChatLeftSide,
|
||||
|
|
@ -254,20 +260,22 @@ class _ChatSettingsState extends State<ChatSettings> {
|
|||
onChanged: (newValue) =>
|
||||
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
||||
),
|
||||
const SectionHeader('Sleep'),
|
||||
const SectionHeader('Sleep', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Prevent sleep in chat-only mode',
|
||||
subtitle: const Text(
|
||||
'Requires restarting the chat in order to take effect.'),
|
||||
'Requires restarting the chat in order to take effect.',
|
||||
),
|
||||
value: settingsStore.chatOnlyPreventSleep,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.chatOnlyPreventSleep = newValue,
|
||||
),
|
||||
const SectionHeader('Autocomplete'),
|
||||
const SectionHeader('Autocomplete', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Show autocomplete bar',
|
||||
subtitle: const Text(
|
||||
'Shows a bar containing matching emotes and mentions while typing.'),
|
||||
'Shows a bar containing matching emotes and mentions while typing.',
|
||||
),
|
||||
value: settingsStore.autocomplete,
|
||||
onChanged: (newValue) => settingsStore.autocomplete = newValue,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class GeneralSettings extends StatelessWidget {
|
|||
onChanged: (newTheme) => settingsStore.themeType =
|
||||
ThemeType.values[themeNames.indexOf(newTheme)],
|
||||
),
|
||||
const SectionHeader('Stream card'),
|
||||
const SectionHeader('Stream card', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Use large stream card',
|
||||
value: settingsStore.largeStreamCard,
|
||||
|
|
@ -35,7 +35,7 @@ class GeneralSettings extends StatelessWidget {
|
|||
value: settingsStore.showThumbnails,
|
||||
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
||||
),
|
||||
const SectionHeader('Links'),
|
||||
const SectionHeader('Links', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Open links in external browser',
|
||||
value: settingsStore.launchUrlExternal,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@ import 'package:flutter_svg/flutter_svg.dart';
|
|||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||
import 'package:frosty/widgets/alert_message.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/dialog.dart';
|
||||
import 'package:frosty/widgets/list_tile.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
|
|
@ -31,11 +28,15 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
Future<void> _showConfirmDialog(BuildContext context) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: 'Reset All Settings',
|
||||
message: 'Are you sure you want to reset all settings?',
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: const Text('Reset all settings'),
|
||||
content: const Text('Are you sure you want to reset all settings?'),
|
||||
actions: [
|
||||
Button(
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
|
||||
|
|
@ -45,18 +46,15 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: AlertMessage(message: 'All settings reset'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: AlertMessage(
|
||||
message: 'All settings reset',
|
||||
centered: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
Button(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
color: Colors.grey,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -66,9 +64,9 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline_rounded),
|
||||
title: 'About Frosty',
|
||||
title: const Text('About Frosty'),
|
||||
onTap: () async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
|
|
@ -87,26 +85,29 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
);
|
||||
},
|
||||
),
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.launch_rounded),
|
||||
title: 'Changelog',
|
||||
title: const Text('Changelog'),
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://github.com/tommyxchow/frosty/releases'),
|
||||
mode: widget.settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppWebView),
|
||||
: LaunchMode.inAppWebView,
|
||||
),
|
||||
FrostyListTile(
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.launch_rounded),
|
||||
title: 'FAQ',
|
||||
onTap: () => launchUrl(Uri.parse('https://www.frostyapp.io/#faq'),
|
||||
title: const Text('FAQ'),
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://www.frostyapp.io/#faq'),
|
||||
mode: widget.settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppWebView),
|
||||
: LaunchMode.inAppWebView,
|
||||
),
|
||||
FrostyListTile(
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline_rounded),
|
||||
title: 'Clear image cache',
|
||||
title: const Text('Clear image cache'),
|
||||
onTap: () async {
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
|
|
@ -115,22 +116,25 @@ class _OtherSettingsState extends State<OtherSettings> {
|
|||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: AlertMessage(message: 'Image cache cleared'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: AlertMessage(
|
||||
message: 'Image cache cleared',
|
||||
centered: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.restore_rounded),
|
||||
title: 'Reset settings',
|
||||
title: const Text('Reset settings'),
|
||||
onTap: () => _showConfirmDialog(context),
|
||||
),
|
||||
Observer(
|
||||
builder: (_) => SettingsListSwitch(
|
||||
title: 'Share crash logs and analytics',
|
||||
subtitle: const Text(
|
||||
'Help improve Frosty by sending anonymous crash logs and analytics through Firebase.'),
|
||||
'Help improve Frosty by sending anonymous crash logs and analytics through Firebase.',
|
||||
),
|
||||
value: widget.settingsStore.shareCrashLogsAndAnalytics,
|
||||
onChanged: (newValue) {
|
||||
widget.settingsStore.shareCrashLogsAndAnalytics = newValue;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ class Settings extends StatelessWidget {
|
|||
Uri.parse('https://www.buymeacoffee.com/tommychow'),
|
||||
mode: settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppWebView),
|
||||
: LaunchMode.inAppWebView,
|
||||
),
|
||||
icon: const Icon(SimpleIcons.buymeacoffee),
|
||||
),
|
||||
IconButton(
|
||||
|
|
@ -42,7 +43,8 @@ class Settings extends StatelessWidget {
|
|||
Uri.parse('https://github.com/tommyxchow/frosty'),
|
||||
mode: settingsStore.launchUrlExternal
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppWebView),
|
||||
: LaunchMode.inAppWebView,
|
||||
),
|
||||
icon: const Icon(SimpleIcons.github),
|
||||
),
|
||||
],
|
||||
|
|
@ -51,9 +53,9 @@ class Settings extends StatelessWidget {
|
|||
bottom: false,
|
||||
child: ListView(
|
||||
children: [
|
||||
const SectionHeader('Account'),
|
||||
const SectionHeader('Profile'),
|
||||
ProfileCard(authStore: context.read<AuthStore>()),
|
||||
const SectionHeader('Customize'),
|
||||
const SectionHeader('Options', showDivider: true),
|
||||
SettingsTileRoute(
|
||||
leading: const Icon(Icons.settings_outlined),
|
||||
title: 'General',
|
||||
|
|
@ -69,8 +71,8 @@ class Settings extends StatelessWidget {
|
|||
title: 'Chat',
|
||||
child: ChatSettings(settingsStore: settingsStore),
|
||||
),
|
||||
const SectionHeader('Other'),
|
||||
OtherSettings(settingsStore: settingsStore)
|
||||
const SectionHeader('Other', showDivider: true),
|
||||
OtherSettings(settingsStore: settingsStore),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import 'package:frosty/apis/twitch_api.dart';
|
|||
import 'package:frosty/constants.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
import 'package:frosty/widgets/dialog.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
|
|
@ -52,8 +50,10 @@ abstract class AuthBase with Store {
|
|||
String? _error;
|
||||
|
||||
/// Navigation handler for the login webview. Fires on every navigation request (whenever the URL changes).
|
||||
FutureOr<NavigationDecision> handleNavigation(
|
||||
{required NavigationRequest request, Widget? routeAfter}) {
|
||||
FutureOr<NavigationDecision> handleNavigation({
|
||||
required NavigationRequest request,
|
||||
Widget? routeAfter,
|
||||
}) {
|
||||
// Check if the URL is the redirect URI.
|
||||
if (request.url.startsWith('https://twitch.tv/login')) {
|
||||
// Extract the token from the query parameters.
|
||||
|
|
@ -119,10 +119,10 @@ abstract class AuthBase with Store {
|
|||
.where((blockedUser) => blockedUser.userId == targetUserId)
|
||||
.isNotEmpty;
|
||||
|
||||
final title = isBlocked ? 'Unblock $targetUser' : 'Block $targetUser';
|
||||
final title = isBlocked ? 'Unblock' : 'Block';
|
||||
|
||||
final message =
|
||||
'Are you sure you want to ${isBlocked ? 'unblock $targetUser?' : 'block $targetUser? This will remove them from channel lists, search results, and chat messages.'}';
|
||||
'Are you sure you want to ${isBlocked ? 'unblock "$targetUser"?' : 'block "$targetUser"? This will remove them from channel lists, search results, and chat messages.'}';
|
||||
|
||||
void onPressed() {
|
||||
if (isBlocked) {
|
||||
|
|
@ -139,19 +139,18 @@ abstract class AuthBase with Store {
|
|||
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => FrostyDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
builder: (context) => AlertDialog.adaptive(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
Button(
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onPressed,
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
Button(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
color: Colors.grey,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -164,7 +164,8 @@ abstract class _SettingsStoreBase with Store {
|
|||
|
||||
@JsonKey(
|
||||
defaultValue: defaultTimestampType,
|
||||
unknownEnumValue: TimestampType.disabled)
|
||||
unknownEnumValue: TimestampType.disabled,
|
||||
)
|
||||
@observable
|
||||
var timestampType = defaultTimestampType;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,9 @@ abstract class UserStoreBase with Store {
|
|||
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
|
||||
}
|
||||
|
||||
_disposeReaction = autorun((_) =>
|
||||
_blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)));
|
||||
_disposeReaction = autorun(
|
||||
(_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)),
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
@ -53,18 +54,23 @@ abstract class UserStoreBase with Store {
|
|||
}
|
||||
|
||||
@action
|
||||
Future<void> unblock(
|
||||
{required String targetId, required Map<String, String> headers}) async {
|
||||
Future<void> unblock({
|
||||
required String targetId,
|
||||
required Map<String, String> headers,
|
||||
}) async {
|
||||
final success =
|
||||
await twitchApi.unblockUser(userId: targetId, headers: headers);
|
||||
if (success) await refreshBlockedUsers(headers: headers);
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> refreshBlockedUsers(
|
||||
{required Map<String, String> headers}) async =>
|
||||
Future<void> refreshBlockedUsers({
|
||||
required Map<String, String> headers,
|
||||
}) async =>
|
||||
_blockedUsers = (await twitchApi.getUserBlockedList(
|
||||
id: _details!.id, headers: headers))
|
||||
id: _details!.id,
|
||||
headers: headers,
|
||||
))
|
||||
.asObservable();
|
||||
|
||||
@action
|
||||
|
|
|
|||
|
|
@ -22,18 +22,20 @@ class VideoSettings extends StatelessWidget {
|
|||
value: settingsStore.showVideo,
|
||||
onChanged: (newValue) => settingsStore.showVideo = newValue,
|
||||
),
|
||||
const SectionHeader('Overlay'),
|
||||
const SectionHeader('Overlay', showDivider: true),
|
||||
SettingsListSwitch(
|
||||
title: 'Use custom video overlay',
|
||||
subtitle: const Text(
|
||||
'Replaces Twitch\'s default web overlay with a mobile-friendly version.'),
|
||||
'Replaces Twitch\'s default web overlay with a mobile-friendly version.',
|
||||
),
|
||||
value: settingsStore.showOverlay,
|
||||
onChanged: (newValue) => settingsStore.showOverlay = newValue,
|
||||
),
|
||||
SettingsListSwitch(
|
||||
title: 'Long-press player to toggle overlay',
|
||||
subtitle: const Text(
|
||||
'Allows switching between Twitch\'s overlay and the custom overlay.'),
|
||||
'Allows switching between Twitch\'s overlay and the custom overlay.',
|
||||
),
|
||||
value: settingsStore.toggleableOverlay,
|
||||
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/widgets/bottom_sheet.dart';
|
||||
|
||||
/// A custom-styled adaptive [ListTile] with options to select.
|
||||
class SettingsListSelect extends StatelessWidget {
|
||||
|
|
@ -21,53 +20,24 @@ class SettingsListSelect extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
isThreeLine: subtitle != null,
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: SizedBox(
|
||||
height: double.infinity,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.8,
|
||||
child: Text(
|
||||
selectedOption,
|
||||
title: Text(title),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: SegmentedButton(
|
||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
segments: options
|
||||
.map(
|
||||
(option) => ButtonSegment(
|
||||
value: option,
|
||||
label: Text(
|
||||
option,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () => showModalBottomSheet(
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => FrostyBottomSheet(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Select ${title.toLowerCase()}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
),
|
||||
...options
|
||||
.map((option) => ListTile(
|
||||
title: Text(option),
|
||||
trailing: selectedOption == option
|
||||
? const Icon(Icons.check_rounded)
|
||||
: null,
|
||||
onTap: () {
|
||||
onChanged(option);
|
||||
Navigator.of(context).pop();
|
||||
}))
|
||||
.toList()
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selected: {selectedOption},
|
||||
onSelectionChanged: (selection) => onChanged(selection.first),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,9 +36,12 @@ class SettingsListSlider extends StatelessWidget {
|
|||
children: [
|
||||
Text(title, style: _textStyle),
|
||||
const Spacer(),
|
||||
Text(trailing,
|
||||
Text(
|
||||
trailing,
|
||||
style: _textStyle.copyWith(
|
||||
fontFeatures: [const FontFeature.tabularFigures()])),
|
||||
fontFeatures: [const FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
|
|||
|
||||
/// A custom-styled adaptive [SwitchListTile].
|
||||
class SettingsListSwitch extends StatelessWidget {
|
||||
final String? title;
|
||||
final String title;
|
||||
final Widget? subtitle;
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
|
||||
const SettingsListSwitch({
|
||||
Key? key,
|
||||
this.title,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.onChanged,
|
||||
required this.value,
|
||||
|
|
@ -19,7 +19,7 @@ class SettingsListSwitch extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return SwitchListTile.adaptive(
|
||||
isThreeLine: subtitle != null,
|
||||
title: Text(title!, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
title: Text(title),
|
||||
subtitle: subtitle,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class SettingsTileRoute extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: leading,
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
title: Text(title),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
|
|
|
|||
156
lib/theme.dart
Normal file
156
lib/theme.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,23 +4,26 @@ import 'package:flutter/material.dart';
|
|||
class AlertMessage extends StatelessWidget {
|
||||
final String message;
|
||||
final Color? color;
|
||||
final bool centered;
|
||||
|
||||
const AlertMessage({
|
||||
Key? key,
|
||||
required this.message,
|
||||
this.centered = true,
|
||||
this.color,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment:
|
||||
centered ? MainAxisAlignment.center : MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: color ?? Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 5.0),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
message,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/app_bar.dart';
|
||||
import 'package:frosty/widgets/bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/list_tile.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
class BlockReportModal extends StatelessWidget {
|
||||
|
|
@ -21,20 +19,23 @@ class BlockReportModal extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FrostyBottomSheet(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return ListView(
|
||||
primary: false,
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
if (authStore.isLoggedIn)
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.block_rounded),
|
||||
onTap: () => authStore
|
||||
.showBlockDialog(context,
|
||||
targetUser: name, targetUserId: userId)
|
||||
.showBlockDialog(
|
||||
context,
|
||||
targetUser: name,
|
||||
targetUserId: userId,
|
||||
)
|
||||
.then((_) => Navigator.pop(context)),
|
||||
title: 'Block $name',
|
||||
title: Text('Block $name'),
|
||||
),
|
||||
FrostyListTile(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.outlined_flag_rounded),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
|
|
@ -55,10 +56,9 @@ class BlockReportModal extends StatelessWidget {
|
|||
},
|
||||
),
|
||||
),
|
||||
title: 'Report $name',
|
||||
)
|
||||
],
|
||||
title: Text('Report $name'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -17,31 +17,28 @@ class FrostyDialog extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
titlePadding: const EdgeInsets.fromLTRB(20.0, 30.0, 20.0, 20.0),
|
||||
contentPadding: const EdgeInsets.fromLTRB(20.0, 0.0, 20.0, 20.0),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
return AlertDialog.adaptive(
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
// style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
// textAlign: TextAlign.center,
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(children: [
|
||||
child: Column(
|
||||
children: [
|
||||
content ?? Text(message!, textAlign: TextAlign.center),
|
||||
if (actions != null) ...[
|
||||
const SizedBox(height: 25.0),
|
||||
const SizedBox(height: 16),
|
||||
...?actions?.map(
|
||||
(action) => Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(top: 5.0),
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: action,
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ class LoadingIndicator extends StatelessWidget {
|
|||
final String? subtitle;
|
||||
final double spacing;
|
||||
|
||||
const LoadingIndicator({Key? key, this.subtitle, this.spacing = 10.0})
|
||||
const LoadingIndicator({Key? key, this.subtitle, this.spacing = 8})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
|
||||
class FrostyNotification extends StatelessWidget {
|
||||
final String message;
|
||||
|
|
@ -18,8 +16,8 @@ class FrostyNotification extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: lightGray,
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
color: Colors.grey.shade900,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(10),
|
||||
child: Row(
|
||||
|
|
@ -49,9 +47,8 @@ class FrostyNotification extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
if (showPasteButton)
|
||||
Button(
|
||||
TextButton(
|
||||
onPressed: onButtonPressed,
|
||||
color: purple,
|
||||
child: const Text('Paste'),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
|
||||
class FrostyPageView extends StatefulWidget {
|
||||
final List<String> headers;
|
||||
|
|
@ -18,63 +16,34 @@ class FrostyPageView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _FrostyPageViewState extends State<FrostyPageView> {
|
||||
var currentIndex = 0;
|
||||
|
||||
late final PageController _pageContoller =
|
||||
PageController(initialPage: currentIndex);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
return DefaultTabController(
|
||||
length: widget.headers.length,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(
|
||||
height: 5.0,
|
||||
thickness: 1.0,
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
...widget.headers.mapIndexed(
|
||||
(index, section) => SizedBox(
|
||||
height: 40,
|
||||
child: Button(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0, vertical: 5.0),
|
||||
onPressed: () => _pageContoller.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
color: index == currentIndex
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Colors.grey,
|
||||
child: Text(
|
||||
section,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
labelPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
tabs: widget.headers
|
||||
.map(
|
||||
(header) => Tab(
|
||||
text: header,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(child: TabBarView(children: widget.children)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
onPageChanged: (index) => setState(() => currentIndex = index),
|
||||
controller: _pageContoller,
|
||||
itemBuilder: (context, index) => widget.children[index],
|
||||
itemCount: widget.children.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageContoller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/apis/twitch_api.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
import 'package:frosty/models/user.dart';
|
||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||
import 'package:frosty/widgets/cached_image.dart';
|
||||
|
|
@ -24,25 +23,21 @@ class ProfilePicture extends StatelessWidget {
|
|||
child: FutureBuilder(
|
||||
future: context.read<TwitchApi>().getUser(
|
||||
userLogin: userLogin,
|
||||
headers: context.read<AuthStore>().headersTwitch),
|
||||
headers: context.read<AuthStore>().headersTwitch,
|
||||
),
|
||||
builder: (context, AsyncSnapshot<UserTwitch> snapshot) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: snapshot.hasData
|
||||
return snapshot.hasData
|
||||
? FrostyCachedNetworkImage(
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
imageUrl: snapshot.data!.profileImageUrl,
|
||||
placeholder: (context, url) =>
|
||||
const ColoredBox(color: lightGray),
|
||||
ColoredBox(color: Colors.grey.shade900),
|
||||
)
|
||||
: Container(
|
||||
color: lightGray,
|
||||
color: Colors.grey.shade900,
|
||||
width: diameter,
|
||||
height: diameter,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/widgets/button.dart';
|
||||
|
||||
class ScrollToTopButton extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
|
|
@ -9,19 +8,24 @@ class ScrollToTopButton extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 5.0),
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Tooltip(
|
||||
message: 'Scroll to top',
|
||||
preferBelow: false,
|
||||
child: Button(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => scrollController.animateTo(
|
||||
0.0,
|
||||
curve: Curves.easeOut,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25.0, vertical: 10.0),
|
||||
child: const Icon(Icons.keyboard_double_arrow_up_rounded),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
),
|
||||
child: const Icon(Icons.arrow_upward_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,37 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/main.dart';
|
||||
|
||||
class SectionHeader extends StatelessWidget {
|
||||
final String text;
|
||||
final EdgeInsets? padding;
|
||||
final double? fontSize;
|
||||
final bool showDivider;
|
||||
|
||||
const SectionHeader(
|
||||
this.text, {
|
||||
Key? key,
|
||||
this.padding,
|
||||
this.fontSize,
|
||||
this.showDivider = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding ?? const EdgeInsets.fromLTRB(16, 30, 16, 5),
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showDivider) const Divider(),
|
||||
Padding(
|
||||
padding: padding ?? const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: purple,
|
||||
fontSize: fontSize ?? 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
460
pubspec.lock
460
pubspec.lock
File diff suppressed because it is too large
Load diff
20
pubspec.yaml
20
pubspec.yaml
|
|
@ -3,7 +3,7 @@ description: A Twitch client for iOS and Android.
|
|||
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
|
|
@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
version: 3.1.4+40
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
|
|
@ -31,13 +31,12 @@ dependencies:
|
|||
sdk: flutter
|
||||
json_annotation: ^4.6.0
|
||||
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
http: ^0.13.3
|
||||
http: ^1.1.0
|
||||
web_socket_channel: ^2.1.0
|
||||
flutter_secure_storage: ^8.0.0
|
||||
flutter_secure_storage: ^9.0.0
|
||||
intl: ^0.18.1
|
||||
cached_network_image: ^3.1.0
|
||||
webview_flutter: ^4.2.0
|
||||
|
|
@ -47,11 +46,11 @@ dependencies:
|
|||
shared_preferences: ^2.0.8
|
||||
flutter_svg: ^2.0.5
|
||||
url_launcher: ^6.0.17
|
||||
package_info_plus: ^3.0.2
|
||||
package_info_plus: ^4.1.0
|
||||
collection: ^1.16.0
|
||||
flutter_cache_manager: ^3.3.0
|
||||
device_info_plus: ^8.0.0
|
||||
wakelock: ^0.6.2
|
||||
device_info_plus: ^9.0.3
|
||||
wakelock_plus: ^1.1.2
|
||||
simple_icons: ^7.10.0
|
||||
simple_pip_mode:
|
||||
git:
|
||||
|
|
@ -85,7 +84,6 @@ dev_dependencies:
|
|||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
|
|
@ -111,6 +109,7 @@ flutter:
|
|||
|
||||
assets:
|
||||
- assets/icons/logo.svg
|
||||
- assets/fonts/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||
|
|
@ -139,7 +138,6 @@ flutter:
|
|||
# see https://flutter.dev/custom-fonts/#from-packages
|
||||
|
||||
flutter_native_splash:
|
||||
|
||||
# This package generates native code to customize Flutter's default white native splash screen
|
||||
# with background color and splash image.
|
||||
# Customize the parameters below, and run the following command in the terminal:
|
||||
|
|
@ -223,7 +221,7 @@ flutter_native_splash:
|
|||
# - 'ios/Runner/Info-Debug.plist'
|
||||
# - 'ios/Runner/Info-Release.plist'
|
||||
|
||||
flutter_icons:
|
||||
flutter_launcher_icons:
|
||||
image_path_android: "assets/icons/icon_android.png"
|
||||
image_path_ios: "assets/icons/icon_ios.png"
|
||||
android: true
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ void main() {
|
|||
'54fa903b01e468494b85b53f',
|
||||
'54fa909b01e468494b85b542',
|
||||
'54fa90ba01e468494b85b543',
|
||||
'54fa90f201e468494b85b545'
|
||||
'54fa90f201e468494b85b545',
|
||||
];
|
||||
final emoteCodes = ['DatSauce', 'ForeverAlone', 'GabeN', 'HailHelix'];
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ void main() {
|
|||
expect(stream.title, 'A shorter title...');
|
||||
expect(stream.viewerCount, 50051);
|
||||
expect(stream.startedAt, '2021-09-23T20:06:22Z');
|
||||
expect(stream.thumbnailUrl,
|
||||
'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg');
|
||||
expect(
|
||||
stream.thumbnailUrl,
|
||||
'https://static-cdn.jtvnw.net/previews-ttv/live_user_xqcow-{width}x{height}.jpg',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ void main() {
|
|||
expect(user.id, '88888888');
|
||||
expect(user.login, 'bob');
|
||||
expect(user.displayName, 'Bob');
|
||||
expect(user.profileImageUrl,
|
||||
'https://static-cdn.jtvnw.net/user-default-pictures-uv/ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png');
|
||||
expect(
|
||||
user.profileImageUrl,
|
||||
'https://static-cdn.jtvnw.net/user-default-pictures-uv/ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue