diff --git a/analysis_options.yaml b/analysis_options.yaml index 770b6979..029e023b 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -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 diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 00000000..a3653cb1 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 8c708bcb..dbc9ea9f 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -7,6 +7,8 @@ @drawable/launch_background false false + false + shortEdges + + + + diff --git a/assets/fonts/Inter-Black.ttf b/assets/fonts/Inter-Black.ttf index 37742709..b27822ba 100644 Binary files a/assets/fonts/Inter-Black.ttf and b/assets/fonts/Inter-Black.ttf differ diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf index 76a215cc..fe23eeb9 100644 Binary files a/assets/fonts/Inter-Bold.ttf and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-ExtraBold.ttf b/assets/fonts/Inter-ExtraBold.ttf index 7ae3a4e8..874b1b0d 100644 Binary files a/assets/fonts/Inter-ExtraBold.ttf and b/assets/fonts/Inter-ExtraBold.ttf differ diff --git a/assets/fonts/Inter-ExtraLight.ttf b/assets/fonts/Inter-ExtraLight.ttf index 131929e5..c993e822 100644 Binary files a/assets/fonts/Inter-ExtraLight.ttf and b/assets/fonts/Inter-ExtraLight.ttf differ diff --git a/assets/fonts/Inter-Light.ttf b/assets/fonts/Inter-Light.ttf index b9c460dc..71188f5c 100644 Binary files a/assets/fonts/Inter-Light.ttf and b/assets/fonts/Inter-Light.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf index 06ae9e9c..a01f3777 100644 Binary files a/assets/fonts/Inter-Medium.ttf and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf index cc73944a..5e4851f0 100644 Binary files a/assets/fonts/Inter-Regular.ttf and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf index 278ceaa3..ecc7041e 100644 Binary files a/assets/fonts/Inter-SemiBold.ttf and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/Inter-Thin.ttf b/assets/fonts/Inter-Thin.ttf index e12ed497..fe77243f 100644 Binary files a/assets/fonts/Inter-Thin.ttf and b/assets/fonts/Inter-Thin.ttf differ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 186821bb..93395104 100644 --- a/ios/Podfile.lock +++ b/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 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 120ffc53..2fc87b86 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -159,7 +159,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a3..a6b826db 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ []; - 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'); diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index 9c653970..26687c40 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -17,8 +17,9 @@ class TwitchApi { const TwitchApi(this._client); /// Returns a list of all Twitch global emotes. - Future> getEmotesGlobal( - {required Map headers}) async { + Future> getEmotesGlobal({ + required Map 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 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> getBadgesGlobal( - {required Map headers}) async { + Future> getBadgesGlobal({ + required Map 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 headers, String? cursor, }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/streams' - : 'https://api.twitch.tv/helix/streams?after=$cursor'); + final url = Uri.parse( + cursor == null + ? 'https://api.twitch.tv/helix/streams' + : 'https://api.twitch.tv/helix/streams?after=$cursor', + ); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); @@ -223,9 +228,11 @@ class TwitchApi { required Map headers, String? cursor, }) async { - 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'); + 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', + ); 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 headers, String? cursor, }) async { - 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'); + 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', + ); 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 headers, }) async { - final url = Uri.parse(id != null - ? 'https://api.twitch.tv/helix/users?id=$id' - : 'https://api.twitch.tv/helix/users?login=$userLogin'); + final url = Uri.parse( + id != null + ? 'https://api.twitch.tv/helix/users?id=$id' + : 'https://api.twitch.tv/helix/users?login=$userLogin', + ); final response = await _client.get(url, headers: headers); final decoded = jsonDecode(response.body); @@ -312,7 +325,8 @@ class TwitchApi { required Map 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 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 headers, String? cursor, }) async { - final url = Uri.parse(cursor == null - ? 'https://api.twitch.tv/helix/games/top' - : 'https://api.twitch.tv/helix/games/top?after=$cursor'); + final url = Uri.parse( + cursor == null + ? 'https://api.twitch.tv/helix/games/top' + : 'https://api.twitch.tv/helix/games/top?after=$cursor', + ); final response = await _client.get(url, headers: headers); final 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 - ? 'https://api.twitch.tv/helix/search/categories?first=8&query=$query' - : 'https://api.twitch.tv/helix/search/categories?first=8&query=$query&after=$cursor'); + final 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', + ); final response = await _client.get(url, headers: headers); if (response.statusCode == 200) { @@ -408,7 +428,8 @@ class TwitchApi { required Map 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 headers, String? cursor, }) async { - 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'); + 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', + ); 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 blockUser( - {required String userId, required Map headers}) async { + Future blockUser({ + required String userId, + required Map 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 unblockUser( - {required String userId, required Map headers}) async { + Future unblockUser({ + required String userId, + required Map 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) { diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index 5349f973..39d94664 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -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', ); } diff --git a/lib/main.dart b/lib/main.dart index 60ec186b..0184ff01 100644 --- a/lib/main.dart +++ b/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(); -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 { return Observer( builder: (context) { final settingsStore = context.read(); + 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 diff --git a/lib/models/badges.dart b/lib/models/badges.dart index 70faf87c..44e51e84 100644 --- a/lib/models/badges.dart +++ b/lib/models/badges.dart @@ -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'; + } + } } diff --git a/lib/models/emotes.dart b/lib/models/emotes.dart index 07118cd6..4a93ed8f 100644 --- a/lib/models/emotes.dart +++ b/lib/models/emotes.dart @@ -184,22 +184,6 @@ class Emote { Map 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'; + } + } } diff --git a/lib/models/irc.dart b/lib/models/irc.dart index b75c102d..f876929a 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -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 generateSpan({ + List 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( + child: InkWell( + onTap: () => _showDetailsBottomSheet( + context, + leading: Stack( + alignment: AlignmentDirectional.center, children: [ - Stack( - alignment: AlignmentDirectional.center, - children: [ - if (nextWordIsEmoji) - Text( - words[index], - style: textStyle?.copyWith(fontSize: 75), - ), - ...emoteStack.reversed.map( - (emote) => FrostyCachedNetworkImage( - imageUrl: emote.url, - height: 80, - useFade: false, - ), - ) - ], - ), - const SizedBox(height: 5.0), - Column( - children: [ - Text( - nextWordIsEmoji - ? '${words[index]} - Emoji' - : '${emoteStack.last.name} - ${emoteType[emoteStack.last.type.index]}', - style: const TextStyle(color: Colors.white), - ), - Text( - nextWordIsEmoji - ? message.join(', ') - : message.skip(1).join(', '), - style: const TextStyle(color: Colors.white), - ), - ], + if (nextWordIsEmoji) + Text( + emoji, + style: textStyle?.copyWith(fontSize: 40), + ), + ...emoteStack.reversed.map( + (emote) => FrostyCachedNetworkImage( + imageUrl: emote.url, + width: 56, + ), ), ], ), + 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( - badge: badge, - size: 80, - backgroundColor: backgroundColor, - isSvg: isSvg, - ), - const SizedBox(height: 5.0), - Text( - badge.name, - style: const TextStyle(color: Colors.white), - ), - ], + child: InkWell( + onTap: () => _showDetailsBottomSheet( + context, + leading: _createBadgeWidget( + badge: badge, + backgroundColor: backgroundColor, + isSvg: isSvg, ), + 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( - imageUrl: emote.url, - height: 80, - useFade: false, - ), - const SizedBox(height: 5.0), - Text( - emote.name, - style: const TextStyle(color: Colors.white), - ), - Text( - emoteType[emote.type.index], - style: const TextStyle(color: Colors.white), - ), - ], + child: InkWell( + onTap: () => _showDetailsBottomSheet( + context, + leading: FrostyCachedNetworkImage( + imageUrl: emote.url, + width: 56, ), + url: emote.url, + title: emote.name, + subtitle: Text(emote.type.toString()), + launchExternal: launchExternal, ), - 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), - mode: launchExternal - ? LaunchMode.externalApplication - : LaunchMode.inAppWebView), + ..onTap = () => launchUrl( + Uri.parse(text), + mode: launchExternal + ? LaunchMode.externalApplication + : 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()); } diff --git a/lib/screens/channel/channel.dart b/lib/screens/channel/channel.dart index 335d643f..2382fd76 100644 --- a/lib/screens/channel/channel.dart +++ b/lib/screens/channel/channel.dart @@ -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 { 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 { streamInfo: _videoStore.streamInfo!, tappableCategory: false, ), - const Divider(height: 1, thickness: 1), + const Divider(), ], ), ), @@ -235,7 +235,8 @@ class _VideoChatState extends State { _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 { 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 { children: settingsStore.landscapeChatLeftSide ? [overlayChat, Expanded(child: overlay)] : [Expanded(child: overlay), overlayChat], - ) + ), ], ) : Row( diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index f3816682..db484d4f 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -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,31 +35,35 @@ 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: Observer( - builder: (context) { - return ListView.builder( - reverse: true, - padding: EdgeInsets.zero, - addAutomaticKeepAlives: false, - controller: chatStore.scrollController, - itemCount: chatStore.renderMessages.length, - itemBuilder: (context, index) => ChatMessage( - ircMessage: chatStore.renderMessages.reversed - .toList()[index], - chatStore: chatStore, - ), - ); - }, + child: Scrollbar( + controller: chatStore.scrollController, + child: Observer( + builder: (context) { + return ListView.builder( + reverse: true, + padding: EdgeInsets.zero, + addAutomaticKeepAlives: false, + controller: chatStore.scrollController, + itemCount: chatStore.renderMessages.length, + itemBuilder: (context, index) => ChatMessage( + ircMessage: chatStore.renderMessages.reversed + .toList()[index], + chatStore: chatStore, + ), + ); + }, + ), ), ), ), 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,31 +122,38 @@ class Chat extends StatelessWidget { switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeIn, child: chatStore.assetsStore.showEmoteMenu - ? FrostyPageView( - headers: const [ - 'Recent', - 'Twitch', - '7TV', - 'BTTV', - 'FFZ', - ], + ? Column( children: [ - RecentEmotesPanel( - chatStore: chatStore, - ), - EmoteMenuPanel( - chatStore: chatStore, - twitchEmotes: chatStore - .assetsStore.userEmoteSectionToEmotes, - ), - ...[ - chatStore.assetsStore.sevenTVEmotes, - chatStore.assetsStore.bttvEmotes, - chatStore.assetsStore.ffzEmotes - ].map( - (emotes) => EmoteMenuPanel( - chatStore: chatStore, - emotes: emotes, + const Divider(), + Expanded( + child: FrostyPageView( + headers: const [ + 'Recent', + 'Twitch', + '7TV', + 'BTTV', + 'FFZ', + ], + children: [ + RecentEmotesPanel( + chatStore: chatStore, + ), + EmoteMenuPanel( + chatStore: chatStore, + twitchEmotes: chatStore + .assetsStore.userEmoteSectionToEmotes, + ), + ...[ + chatStore.assetsStore.sevenTVEmotes, + chatStore.assetsStore.bttvEmotes, + chatStore.assetsStore.ffzEmotes, + ].map( + (emotes) => EmoteMenuPanel( + chatStore: chatStore, + emotes: emotes, + ), + ), + ], ), ), ], diff --git a/lib/screens/channel/chat/details/chat_details.dart b/lib/screens/channel/chat/details/chat_details.dart index 544a9705..c0ef151b 100644 --- a/lib/screens/channel/chat/details/chat_details.dart +++ b/lib/screens/channel/chat/details/chat_details.dart @@ -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,90 +23,97 @@ class ChatDetails extends StatelessWidget { required this.userLogin, }) : super(key: key); - Future _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 _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, - children: [ - Opacity( - opacity: chatStore.sleepTimer != null && - chatStore.sleepTimer!.isActive - ? 1.0 - : 0.5, - child: Row( - children: [ - const Icon(Icons.timer_rounded), - Text( - ' ${chatStore.timeRemaining.toString().split('.')[0]}', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontFeatures: [FontFeature.tabularFigures()]), - ), - const Spacer(), - IconButton( - tooltip: 'Cancel sleep timer', - onPressed: chatStore.cancelSleepTimer, - icon: const Icon(Icons.cancel_rounded), - ), - ], - ), - ), - Row( - children: [ - DropdownButton( - value: chatStore.sleepHours, - items: List.generate(24, (index) => index) - .map((e) => DropdownMenuItem( - value: e, child: Text(e.toString()))) - .toList(), - onChanged: (int? hours) => chatStore.sleepHours = hours!, - menuMaxHeight: 200, - ), - const SizedBox(width: 10.0), - const Text('Hours'), - ], - ), - Row( - children: [ - DropdownButton( - value: chatStore.sleepMinutes, - items: List.generate(60, (index) => index) - .map((e) => DropdownMenuItem( - value: e, child: Text(e.toString()))) - .toList(), - onChanged: (int? minutes) => - chatStore.sleepMinutes = minutes!, - menuMaxHeight: 200, - ), - const SizedBox(width: 10.0), - const Text('Minutes'), - ], - ), - ], + builder: (context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader( + 'Sleep timer', + padding: EdgeInsets.fromLTRB(16, 0, 16, 4), ), - ), - actions: [ - Observer( - builder: (context) => Button( - onPressed: - chatStore.sleepHours == 0 && chatStore.sleepMinutes == 0 - ? null - : () => chatStore.updateSleepTimer( - onTimerFinished: () => navigatorKey.currentState - ?.popUntil((route) => route.isFirst), + Expanded( + child: ListView( + children: [ + if (chatStore.sleepTimer?.isActive == true) + Observer( + builder: (context) { + return ListTile( + leading: const Icon(Icons.close_rounded), + title: Text( + 'Turn off (${formatTimeLeft(chatStore.timeRemaining)})', + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], ), - child: const Text('Set timer'), + ), + onTap: () { + chatStore.cancelSleepTimer(); + + Navigator.of(context).pop(); + }, + ); + }, + ), + ...durations.map( + (duration) => ListTile( + leading: const Icon(Icons.hourglass_top_rounded), + title: Text(formatDuration(duration)), + onTap: () { + chatStore.updateSleepTimer( + duration: duration, + onTimerFinished: () => navigatorKey.currentState + ?.popUntil((route) => route.isFirst), + ); + + Navigator.of(context).pop(); + }, + ), + ), + ], ), ), - Button( - onPressed: Navigator.of(context).pop, - color: Colors.grey, - child: const Text('Close'), - ), ], ), ); @@ -117,19 +122,19 @@ class ChatDetails extends StatelessWidget { Future _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,62 +143,76 @@ 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( - height: MediaQuery.of(context).size.height * 0.8, - child: GestureDetector( - onTap: FocusScope.of(context).unfocus, - child: ChattersList( - chatDetailsStore: chatDetailsStore, - chatStore: chatStore, - userLogin: userLogin, - ), + builder: (context) => SizedBox( + height: MediaQuery.of(context).size.height * 0.8, + child: GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: ChattersList( + chatDetailsStore: chatDetailsStore, + chatStore: chatStore, + userLogin: userLogin, ), ), ), ), ), - FrostyListTile( - leading: const Icon(Icons.timer_outlined), - title: 'Sleep timer', - onTap: () => _showSleepTimerDialog(context), + Observer( + builder: (context) { + return ListTile( + leading: const Icon(Icons.timer_outlined), + title: Text( + 'Sleep timer ${chatStore.timeRemaining.inSeconds > 0 ? 'on (${formatTimeLeft(chatStore.timeRemaining)})' : ''}', + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + onTap: () => _showSleepTimer(context), + ); + }, ), - FrostyListTile( + ListTile( leading: const Icon(Icons.delete_outline_rounded), - 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( - children: children, - ), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, children: children), + return ListView( + shrinkWrap: true, + primary: false, + children: children, ); } } diff --git a/lib/screens/channel/chat/details/chat_users_list.dart b/lib/screens/channel/chat/details/chat_users_list.dart index 5eabc983..b5458acc 100644 --- a/lib/screens/channel/chat/details/chat_users_list.dart +++ b/lib/screens/channel/chat/details/chat_users_list.dart @@ -33,7 +33,7 @@ class _ChattersListState extends State { 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 { 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 { ), ), Expanded( - child: RefreshIndicator( + child: RefreshIndicator.adaptive( onRefresh: () async { HapticFeedback.lightImpact(); @@ -76,91 +75,89 @@ class _ChattersListState extends State { child: Stack( alignment: AlignmentDirectional.bottomCenter, children: [ - Observer( - builder: (context) { - return CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - controller: widget.chatDetailsStore.scrollController, - slivers: [ - if (widget.chatDetailsStore.filterText.isEmpty) - SliverPadding( - padding: const EdgeInsets.only( - left: 10.0, - right: 10.0, - top: 20.0, - bottom: 10.0, - ), - sliver: SliverToBoxAdapter( - child: Text( - '${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18.0, + Scrollbar( + controller: widget.chatDetailsStore.scrollController, + child: Observer( + builder: (context) { + return CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: widget.chatDetailsStore.scrollController, + slivers: [ + if (widget.chatDetailsStore.filterText.isEmpty) + SliverPadding( + padding: const EdgeInsets.all(12), + sliver: SliverToBoxAdapter( + child: Text( + '${NumberFormat().format(widget.chatDetailsStore.chatUsers.length)} chatters found', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, + ), ), ), ), - ), - if (widget.chatDetailsStore.chatUsers.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( + if (widget.chatDetailsStore.chatUsers.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, child: AlertMessage(message: 'No chatters found'), - ), - ) - else if (widget.chatDetailsStore.filteredUsers.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( + ) + else if (widget + .chatDetailsStore.filteredUsers.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, child: AlertMessage(message: 'No matching chatters'), - ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => InkWell( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, vertical: 5.0), - child: Text(widget - .chatDetailsStore.filteredUsers - .elementAt(index)), - ), - onLongPress: () async { - HapticFeedback.lightImpact(); - - final userInfo = await context - .read() - .getUser( - headers: context - .read() - .headersTwitch, - userLogin: widget - .chatDetailsStore.filteredUsers - .elementAt(index)); - - if (!mounted) return; - - showModalBottomSheet( - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) => ChatUserModal( - chatStore: widget.chatStore, - username: userInfo.login, - userId: userInfo.id, - displayName: userInfo.displayName, + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => InkWell( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, ), - ); - }, + child: Text( + widget.chatDetailsStore.filteredUsers + .elementAt(index), + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + ), + ), + onTap: () async { + final userInfo = + await context.read().getUser( + headers: context + .read() + .headersTwitch, + userLogin: widget.chatDetailsStore + .filteredUsers + .elementAt(index), + ); + + if (!mounted) return; + + showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => ChatUserModal( + chatStore: widget.chatStore, + username: userInfo.login, + userId: userInfo.id, + displayName: userInfo.displayName, + ), + ); + }, + ), + childCount: widget + .chatDetailsStore.filteredUsers.length, ), - childCount: - widget.chatDetailsStore.filteredUsers.length, ), - ), - ], - ); - }, + ], + ); + }, + ), ), Observer( builder: (context) => AnimatedSwitcher( @@ -170,7 +167,8 @@ class _ChattersListState extends State { child: widget.chatDetailsStore.showJumpButton ? ScrollToTopButton( scrollController: - widget.chatDetailsStore.scrollController) + widget.chatDetailsStore.scrollController, + ) : null, ), ), diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart index f6a18f93..fb8fa606 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_panel.dart @@ -20,18 +20,22 @@ class EmoteMenuPanel extends StatelessWidget { Widget build(BuildContext context) { if (emotes != null) { final globalEmotes = emotes! - .where((emote) => - emote.type == EmoteType.bttvGlobal || - emote.type == EmoteType.ffzGlobal || - emote.type == EmoteType.sevenTVGlobal) + .where( + (emote) => + emote.type == EmoteType.bttvGlobal || + emote.type == EmoteType.ffzGlobal || + emote.type == EmoteType.sevenTVGlobal, + ) .toList(); final channelEmotes = emotes! - .where((emote) => - emote.type == EmoteType.bttvChannel || - emote.type == EmoteType.bttvShared || - emote.type == EmoteType.ffzChannel || - emote.type == EmoteType.sevenTVChannel) + .where( + (emote) => + emote.type == EmoteType.bttvChannel || + emote.type == EmoteType.bttvShared || + emote.type == EmoteType.ffzChannel || + emote.type == EmoteType.sevenTVChannel, + ) .toList(); return FrostyPageView( diff --git a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart index 2bb49284..8528425b 100644 --- a/lib/screens/channel/chat/emote_menu/emote_menu_section.dart +++ b/lib/screens/channel/chat/emote_menu/emote_menu_section.dart @@ -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 emotes; @@ -16,8 +16,16 @@ class EmoteMenuSection extends StatelessWidget { required this.emotes, }) : super(key: key); + @override + State createState() => _EmoteMenuSectionState(); +} + +class _EmoteMenuSectionState extends State + 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; } diff --git a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart index 16b13c9f..0e08d9b2 100644 --- a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -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) => - existingEmote.name == emote.name && - existingEmote.type == emote.type); + final matchingEmotes = validEmotes.where( + (existingEmote) => + existingEmote.name == emote.name && + existingEmote.type == emote.type, + ); return InkWell( onTap: matchingEmotes.isNotEmpty @@ -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 diff --git a/lib/screens/channel/chat/stores/chat_assets_store.dart b/lib/screens/channel/chat/stores/chat_assets_store.dart index 3c2af666..77b8968a 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -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 badgesFuture({ @@ -177,10 +182,12 @@ abstract class ChatAssetsStoreBase with Store { twitchApi .getBadgesGlobal(headers: headers) .then((badges) => twitchBadgesToObject.addAll(badges)) - .then((_) => twitchApi - .getBadgesChannel(id: channelId, headers: headers) - .then((badges) => twitchBadgesToObject.addAll(badges)) - .catchError(onError)), + .then( + (_) => twitchApi + .getBadgesChannel(id: channelId, headers: headers) + .then((badges) => twitchBadgesToObject.addAll(badges)) + .catchError(onError), + ), ffzApi .getBadges() .then((badges) => _userToFFZBadges = badges) @@ -201,15 +208,21 @@ abstract class ChatAssetsStoreBase with Store { required Map headers, required Function onError, }) async { - final userEmotes = await Future.wait(emoteSets.map((setId) => twitchApi - .getEmotesSets(setId: setId, headers: headers) - .catchError(onError))); + final userEmotes = await Future.wait( + emoteSets.map( + (setId) => twitchApi + .getEmotesSets(setId: setId, headers: headers) + .catchError(onError), + ), + ); for (final emoteSet in userEmotes) { if (emoteSet.isNotEmpty) { if (emoteSet.first.type == EmoteType.twitchSub) { final owner = await twitchApi.getUser( - id: emoteSet.first.ownerId, headers: headers); + id: emoteSet.first.ownerId, + headers: headers, + ); _userEmoteSectionToEmotes.update( owner.displayName, (existingEmoteSet) => [...existingEmoteSet, ...emoteSet], diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 0094f775..2158a3d3 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -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 = []; + final messageBuffer = ObservableList(); /// 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(); } } diff --git a/lib/screens/channel/chat/stores/chat_store.g.dart b/lib/screens/channel/chat/stores/chat_store.g.dart index 042a70d7..57ef152f 100644 --- a/lib/screens/channel/chat/stores/chat_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_store.g.dart @@ -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} diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index d40aff4b..1ad8e1ee 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -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 - ? Icons.emoji_emotions_rounded - : Icons.emoji_emotions_outlined), + icon: Icon( + chatStore.assetsStore.showEmoteMenu + ? Icons.emoji_emotions_rounded + : 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( diff --git a/lib/screens/channel/chat/widgets/chat_message.dart b/lib/screens/channel/chat/widgets/chat_message.dart index ea53102f..c7093be3 100644 --- a/lib/screens/channel/chat/widgets/chat_message.dart +++ b/lib/screens/channel/chat/widgets/chat_message.dart @@ -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, @@ -255,8 +266,9 @@ class ChatMessage extends StatelessWidget { final paddedMessage = Padding( padding: EdgeInsets.symmetric( - vertical: chatStore.settings.messageSpacing / 2, - horizontal: 10.0), + vertical: chatStore.settings.messageSpacing / 2, + 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; diff --git a/lib/screens/channel/chat/widgets/chat_user_modal.dart b/lib/screens/channel/chat/widgets/chat_user_modal.dart index ae7765ca..d983e609 100644 --- a/lib/screens/channel/chat/widgets/chat_user_modal.dart +++ b/lib/screens/channel/chat/widgets/chat_user_modal.dart @@ -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,92 +32,95 @@ class _ChatUserModalState extends State { ? widget.displayName : '${widget.displayName} (${widget.username})'; - return FrostyBottomSheet( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - contentPadding: const EdgeInsets.all(10.0), - leading: ProfilePicture( - userLogin: widget.username, - ), - title: Row( - children: [ - Flexible( - child: Tooltip( - message: name, - child: Text( - name, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.bold), + return SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: const EdgeInsets.all(12), + leading: ProfilePicture( + userLogin: widget.username, + ), + title: Row( + children: [ + Flexible( + child: Tooltip( + message: name, + child: Text( + name, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.chatStore.auth.isLoggedIn) + IconButton( + tooltip: 'Reply', + onPressed: () { + widget.chatStore.textController.text = + '@${widget.username} '; + Navigator.pop(context); + widget.chatStore.textFieldFocusNode.requestFocus(); + }, + icon: const Icon(Icons.reply_rounded), + ), + IconButton( + tooltip: 'More', + onPressed: () => showModalBottomSheet( + context: context, + builder: (context) => BlockReportModal( + authStore: widget.chatStore.auth, + name: name, + userLogin: widget.username, + userId: widget.userId, + ), + ), + icon: Icon(Icons.adaptive.more_rounded), + ), + ], + ), + ), + Expanded( + child: Observer( + builder: (context) { + final userMessages = widget.chatStore.messages.reversed + .where((message) => message.user == widget.username) + .toList(); + + if (userMessages.isEmpty) { + return const AlertMessage(message: 'No recent messages'); + } + + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: widget.chatStore.settings.messageScale, + ), + child: DefaultTextStyle( + style: DefaultTextStyle.of(context) + .style + .copyWith(fontSize: widget.chatStore.settings.fontSize), + child: ListView.builder( + reverse: true, + primary: false, + itemCount: userMessages.length, + itemBuilder: (context, index) => ChatMessage( + ircMessage: userMessages[index], + chatStore: widget.chatStore, + isModal: true, ), ), ), - ], - ), - trailing: widget.chatStore.auth.isLoggedIn - ? Button( - padding: const EdgeInsets.symmetric(horizontal: 15.0), - onPressed: () { - widget.chatStore.textController.text = - '@${widget.username} '; - Navigator.pop(context); - widget.chatStore.textFieldFocusNode.requestFocus(); - }, - child: const Text('Reply'), - ) - : null, - onTap: () => showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) => BlockReportModal( - authStore: widget.chatStore.auth, - name: name, - userLogin: widget.username, - userId: widget.userId, - ), - ), + ); + }, ), - const SectionHeader( - 'Recent messages', - padding: EdgeInsets.all(10.0), - ), - Expanded( - child: Observer( - builder: (context) { - final userMessages = widget.chatStore.messages.reversed - .where((message) => message.user == widget.username) - .toList(); - - if (userMessages.isEmpty) { - return const AlertMessage(message: 'No recent messages'); - } - - return MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaleFactor: - widget.chatStore.settings.messageScale), - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: widget.chatStore.settings.fontSize), - child: ListView.builder( - reverse: true, - itemCount: userMessages.length, - itemBuilder: (context, index) => ChatMessage( - ircMessage: userMessages[index], - chatStore: widget.chatStore, - isModal: true, - ), - ), - ), - ); - }, - ), - ) - ], - ), + ), + ], ), ); } diff --git a/lib/screens/channel/chat/widgets/reply_thread.dart b/lib/screens/channel/chat/widgets/reply_thread.dart new file mode 100644 index 00000000..555bc3bf --- /dev/null +++ b/lib/screens/channel/chat/widgets/reply_thread.dart @@ -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, + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/screens/channel/video/video.dart b/lib/screens/channel/video/video.dart index b18c0bd4..2480f0c3 100644 --- a/lib/screens/channel/video/video.dart +++ b/lib/screens/channel/video/video.dart @@ -31,7 +31,8 @@ class _VideoState extends State