From ef846a4596ceeffe2fa47116d4ca851f0a19535b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 01:29:24 -0500 Subject: [PATCH 001/172] Bump fastlane from 2.227.0 to 2.230.0 in /ios (#480) Bumps [fastlane](https://github.com/fastlane/fastlane) from 2.227.0 to 2.230.0. - [Release notes](https://github.com/fastlane/fastlane/releases) - [Changelog](https://github.com/fastlane/fastlane/blob/master/CHANGELOG.latest.md) - [Commits](https://github.com/fastlane/fastlane/compare/fastlane/2.227.0...fastlane/2.230.0) --- updated-dependencies: - dependency-name: fastlane dependency-version: 2.230.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Tommy Chow --- ios/Gemfile.lock | 84 ++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index 11e22562..06923c0a 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -1,47 +1,47 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.2) - aws-partitions (1.1082.0) - aws-sdk-core (3.222.1) + aws-eventstream (1.4.0) + aws-partitions (1.1200.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 + bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.99.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.183.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-s3 (1.209.0) + aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) + bigdecimal (4.0.1) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.109.0) + excon (0.112.0) faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -54,14 +54,14 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.1.0) + faraday-multipart (1.2.0) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) @@ -71,15 +71,18 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.4.0) - fastlane (2.227.0) + fastlane (2.230.0) CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) bundler (>= 1.12.0, < 3.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -97,9 +100,12 @@ GEM http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) naturally (~> 2.2) + nkf (~> 0.2.0) optparse (>= 0.1.1, < 1.0.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) @@ -111,7 +117,7 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.4.0) + xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.0.0) sysrandom (~> 1.0) @@ -130,19 +136,19 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.29.0) + google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.6.1) + google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.3.1) - google-cloud-storage (1.45.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.29.0) + google-apis-storage_v1 (~> 0.31.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) @@ -158,37 +164,37 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.7.6) - jwt (2.10.1) + json (2.18.0) + jwt (2.10.2) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + multi_json (1.19.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) - naturally (2.2.1) + naturally (2.3.0) nkf (0.2.0) - optparse (0.6.0) + optparse (0.8.1) os (1.1.4) plist (3.7.2) - public_suffix (5.1.1) - rake (13.2.1) + public_suffix (7.0.0) + rake (13.3.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.4.1) + rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) - signet (0.18.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList @@ -203,7 +209,6 @@ GEM tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.2.0) unicode-display_width (2.6.0) word_wrap (1.0.0) xcodeproj (1.27.0) @@ -225,6 +230,7 @@ PLATFORMS universal-darwin-24 x86_64-darwin-20 x86_64-darwin-21 + x86_64-linux DEPENDENCIES fastlane From ba28e09e58f17677310c098e605947b82e631c31 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 15:28:26 -0500 Subject: [PATCH 002/172] downgrade `flutter_secure_storage`, safer async ops potential fix for #484 --- lib/main.dart | 14 +++++++++++--- pubspec.lock | 44 ++++++++++++++++++++++++++------------------ pubspec.yaml | 2 +- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 59ad27ed..46a6e08b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -54,11 +54,16 @@ void main() async { // Workaround for clearing stored tokens on uninstall. // If first time running app, will clear all tokens in the secure storage. + // Run non-blocking to avoid ANR on slow Android devices (especially Android 14). if (firstRun) { debugPrint('Clearing secure storage...'); const storage = FlutterSecureStorage(); - await storage.deleteAll(); + // Don't block - run in background after app starts. + // This is safe because first run means no active session depends on this data. + unawaited(storage.deleteAll().catchError((e) { + debugPrint('Error clearing secure storage: $e'); + })); } await initUtils(); @@ -183,8 +188,11 @@ class _MyAppState extends State { handleDeepLink(uri); }); - // Handle the initial link if app was opened from a link - final initialLink = await _appLinks.getInitialLink(); + // Handle the initial link if app was opened from a link. + // Add timeout to prevent indefinite blocking on certain Android lifecycle states. + final initialLink = await _appLinks + .getInitialLink() + .timeout(const Duration(seconds: 3), onTimeout: () => null); if (initialLink != null) { handleDeepLink(initialLink); } diff --git a/pubspec.lock b/pubspec.lock index 898b2989..ec8c083a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -490,50 +490,50 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" url: "https://pub.dev" source: hosted - version: "10.0.0" - flutter_secure_storage_darwin: - dependency: transitive - description: - name: flutter_secure_storage_darwin - sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" - url: "https://pub.dev" - source: hosted - version: "0.2.0" + version: "9.2.4" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "1.1.2" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "1.2.1" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "3.1.2" flutter_svg: dependency: "direct main" description: @@ -648,6 +648,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 40234a08..49a91e70 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 web_socket_channel: ^3.0.0 - flutter_secure_storage: ^10.0.0 + flutter_secure_storage: ^9.2.2 intl: ^0.20.1 cached_network_image: ^3.1.0 webview_flutter: ^4.2.0 From f3f5629b75834a589cfc5d81ebaf0a8b752323f1 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 15:48:54 -0500 Subject: [PATCH 003/172] restore show latency setting part of #485 --- lib/screens/channel/video/video_overlay.dart | 53 ++++++++++--------- .../settings/stores/settings_store.dart | 6 +++ .../settings/stores/settings_store.g.dart | 21 ++++++++ lib/screens/settings/video_settings.dart | 8 +++ 4 files changed, 62 insertions(+), 26 deletions(-) diff --git a/lib/screens/channel/video/video_overlay.dart b/lib/screens/channel/video/video_overlay.dart index 0a03748c..e6668b84 100644 --- a/lib/screens/channel/video/video_overlay.dart +++ b/lib/screens/channel/video/video_overlay.dart @@ -487,35 +487,36 @@ class VideoOverlay extends StatelessWidget { ), ), ), - Tooltip( - message: 'Latency to broadcaster', - preferBelow: false, - triggerMode: TooltipTriggerMode.tap, - child: Row( - spacing: 4, - children: [ - Icon( - Icons.speed_rounded, - size: 14, - color: surfaceColor, - shadows: _iconShadow, - ), - Observer( - builder: (context) => Text( - videoStore.latency ?? '—', - style: TextStyle( - color: surfaceColor, - fontWeight: FontWeight.w500, - fontFeatures: const [ - FontFeature.tabularFigures(), - ], - shadows: _textShadow, + if (settingsStore.showLatency) + Tooltip( + message: 'Latency to broadcaster', + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + child: Row( + spacing: 4, + children: [ + Icon( + Icons.speed_rounded, + size: 14, + color: surfaceColor, + shadows: _iconShadow, + ), + Observer( + builder: (context) => Text( + videoStore.latency ?? '—', + style: TextStyle( + color: surfaceColor, + fontWeight: FontWeight.w500, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + shadows: _textShadow, + ), ), ), - ), - ], + ], + ), ), - ), ], ), ), diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 37b38590..03a0cb86 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -69,6 +69,7 @@ abstract class _SettingsStoreBase with Store { // Overlay defaults static const defaultShowOverlay = true; static const defaultToggleableOverlay = false; + static const defaultShowLatency = true; // Player options @JsonKey(defaultValue: defaultShowVideo) @@ -92,6 +93,10 @@ abstract class _SettingsStoreBase with Store { @observable var toggleableOverlay = defaultToggleableOverlay; + @JsonKey(defaultValue: defaultShowLatency) + @observable + var showLatency = defaultShowLatency; + @action void resetVideoSettings() { showVideo = defaultShowVideo; @@ -100,6 +105,7 @@ abstract class _SettingsStoreBase with Store { showOverlay = defaultShowOverlay; toggleableOverlay = defaultToggleableOverlay; + showLatency = defaultShowLatency; } // * Chat Settings diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 871f81eb..3a4fe929 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -25,6 +25,7 @@ SettingsStore _$SettingsStoreFromJson( ..useEnhancedRendering = json['useEnhancedRendering'] as bool? ?? false ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false + ..showLatency = json['showLatency'] as bool? ?? true ..badgeScale = (json['badgeScale'] as num?)?.toDouble() ?? 1.0 ..emoteScale = (json['emoteScale'] as num?)?.toDouble() ?? 1.0 ..messageScale = (json['messageScale'] as num?)?.toDouble() ?? 1.0 @@ -101,6 +102,7 @@ Map _$SettingsStoreToJson( 'useEnhancedRendering': instance.useEnhancedRendering, 'showOverlay': instance.showOverlay, 'toggleableOverlay': instance.toggleableOverlay, + 'showLatency': instance.showLatency, 'badgeScale': instance.badgeScale, 'emoteScale': instance.emoteScale, 'messageScale': instance.messageScale, @@ -352,6 +354,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store { }); } + late final _$showLatencyAtom = Atom( + name: '_SettingsStoreBase.showLatency', + context: context, + ); + + @override + bool get showLatency { + _$showLatencyAtom.reportRead(); + return super.showLatency; + } + + @override + set showLatency(bool value) { + _$showLatencyAtom.reportWrite(value, super.showLatency, () { + super.showLatency = value; + }); + } + late final _$badgeScaleAtom = Atom( name: '_SettingsStoreBase.badgeScale', context: context, @@ -1104,6 +1124,7 @@ defaultToHighestQuality: ${defaultToHighestQuality}, useEnhancedRendering: ${useEnhancedRendering}, showOverlay: ${showOverlay}, toggleableOverlay: ${toggleableOverlay}, +showLatency: ${showLatency}, badgeScale: ${badgeScale}, emoteScale: ${emoteScale}, messageScale: ${messageScale}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index e4894e65..773bf4cb 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -58,6 +58,14 @@ class VideoSettings extends StatelessWidget { value: settingsStore.toggleableOverlay, onChanged: (newValue) => settingsStore.toggleableOverlay = newValue, ), + SettingsListSwitch( + title: 'Show latency', + subtitle: const Text( + 'Displays the stream latency in the video overlay.', + ), + value: settingsStore.showLatency, + onChanged: (newValue) => settingsStore.showLatency = newValue, + ), ], ), ); From 0bdf9ab29063d91d5105af7b03f666ae5d05fb0f Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 15:51:09 -0500 Subject: [PATCH 004/172] re-enable impeller on android --- android/app/src/main/AndroidManifest.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 36beb448..7b2ba6ba 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,9 +46,6 @@ - Date: Fri, 2 Jan 2026 16:05:44 -0500 Subject: [PATCH 005/172] make video enhanced rendering opt-out --- lib/screens/settings/stores/settings_store.dart | 2 +- lib/screens/settings/stores/settings_store.g.dart | 2 +- lib/screens/settings/video_settings.dart | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 03a0cb86..f394b492 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -64,7 +64,7 @@ abstract class _SettingsStoreBase with Store { // Player defaults static const defaultShowVideo = true; static const defaultDefaultToHighestQuality = false; - static const defaultUseEnhancedRendering = false; + static const defaultUseEnhancedRendering = true; // Overlay defaults static const defaultShowOverlay = true; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 3a4fe929..fe29eaf1 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -22,7 +22,7 @@ SettingsStore _$SettingsStoreFromJson( ..launchUrlExternal = json['launchUrlExternal'] as bool? ?? false ..showVideo = json['showVideo'] as bool? ?? true ..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false - ..useEnhancedRendering = json['useEnhancedRendering'] as bool? ?? false + ..useEnhancedRendering = json['useEnhancedRendering'] as bool? ?? true ..showOverlay = json['showOverlay'] as bool? ?? true ..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false ..showLatency = json['showLatency'] as bool? ?? true diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 773bf4cb..90002fbf 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -35,7 +35,7 @@ class VideoSettings extends StatelessWidget { SettingsListSwitch( title: 'Use enhanced rendering', subtitle: const Text( - 'Enables a newer WebView rendering method that improves performance. May cause random crashes on some devices.', + 'Uses a faster WebView rendering method. Disable if you experience crashes while watching streams.', ), value: settingsStore.useEnhancedRendering, onChanged: (newValue) => From 1ebf6f32e0eb1be24afffbc07c8f85564d09071a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 16:43:46 -0500 Subject: [PATCH 006/172] release v5.0.1 --- assets/release-notes.md | 16 ++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index 6053ad11..5d87fabe 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,19 @@ +## v5.0.1 + +January 2, 2026 + +### Improvements + +- [Android] Enhanced video rendering is now enabled by default for better performance. Disable in settings if you experience crashes. +- [Android] Re-enabled Flutter Impeller rendering engine for better performance. +- Restored the "Show latency" setting in video overlay options. + +### Bug Fixes + +- [Android] Potentially fixed crash on app startup related to secure storage. + +--- + ## v5.0.0 January 1, 2026 diff --git a/pubspec.yaml b/pubspec.yaml index 49a91e70..71604401 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 5.0.0+63 +version: 5.0.1+64 environment: sdk: ^3.9.0 From 8b54b3ba1b08992e3592975d7d7f2d59c300ffeb Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 18:08:30 -0500 Subject: [PATCH 007/172] make search page type to search --- lib/screens/home/search/search.dart | 4 +- .../search/search_results_categories.dart | 7 +++ .../home/search/search_results_channels.dart | 8 ++++ lib/screens/home/search/search_store.dart | 41 +++++++++++++++++ lib/screens/home/search/search_store.g.dart | 44 +++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index e1cb1517..adcc8bf0 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -41,7 +41,7 @@ class _SearchState extends State { Positioned.fill( child: Observer( builder: (context) { - if (_searchStore.textEditingController.text.isEmpty) { + if (_searchStore.searchText.isEmpty) { if (_searchStore.searchHistory.isEmpty) { // Keep controller attached so borders stay in sync return CustomScrollView( @@ -202,6 +202,7 @@ class _SearchState extends State { controller: _searchStore.textEditingController, focusNode: _searchStore.textFieldFocusNode, autocorrect: false, + onChanged: _searchStore.onSearchTextChanged, decoration: InputDecoration( prefixIcon: const Icon(Icons.search_rounded), hintText: 'Search channels or categories', @@ -218,6 +219,7 @@ class _SearchState extends State { _searchStore.textFieldFocusNode.unfocus(); } _searchStore.textEditingController.clear(); + _searchStore.onSearchTextChanged(''); // Ensure borders reset: scroll back to top and trigger listeners if (widget.scrollController.hasClients) { widget.scrollController.animateTo( diff --git a/lib/screens/home/search/search_results_categories.dart b/lib/screens/home/search/search_results_categories.dart index 45690520..2f7634fc 100644 --- a/lib/screens/home/search/search_results_categories.dart +++ b/lib/screens/home/search/search_results_categories.dart @@ -18,7 +18,14 @@ class SearchResultsCategories extends StatelessWidget { builder: (context) { final future = searchStore.categoryFuture; + // Show skeletons immediately while waiting for debounce. if (future == null) { + if (searchStore.isSearching) { + return SliverList.builder( + itemCount: 8, + itemBuilder: (context, index) => const CategorySkeletonLoader(), + ); + } return const SliverToBoxAdapter(child: SizedBox.shrink()); } diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index cbb2bc46..f7413995 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -72,7 +72,15 @@ class _SearchResultsChannelsState extends State { builder: (context) { final future = widget.searchStore.channelFuture; + // Show skeletons immediately while waiting for debounce. if (future == null) { + if (widget.searchStore.isSearching) { + return SliverList.builder( + itemCount: 8, + itemBuilder: (context, index) => + ChannelSkeletonLoader(index: index), + ); + } return const SliverToBoxAdapter(child: SizedBox.shrink()); } diff --git a/lib/screens/home/search/search_store.dart b/lib/screens/home/search/search_store.dart index 6dc8e67f..7afe3318 100644 --- a/lib/screens/home/search/search_store.dart +++ b/lib/screens/home/search/search_store.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/models/category.dart'; @@ -19,12 +21,17 @@ abstract class SearchStoreBase with Store { final textFieldFocusNode = FocusNode(); + Timer? _debounceTimer; + @readonly var _searchText = ''; @readonly var _searchHistory = ObservableList(); + @readonly + var _isSearching = false; + @readonly ObservableFuture>? _channelFuture; @@ -57,15 +64,48 @@ abstract class SearchStoreBase with Store { ); } + /// Debounced handler for search-as-you-type. + @action + void onSearchTextChanged(String query) { + _debounceTimer?.cancel(); + + if (query.isEmpty) { + _isSearching = false; + _channelFuture = null; + _categoryFuture = null; + return; + } + + // Show loading state immediately for responsive feedback. + _isSearching = true; + + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + runInAction(() => _performSearch(query)); + }); + } + /// Obtain the channels and categories that match the provided [query]. + /// Adds the query to search history. @action void handleQuery(String query) { if (query.isEmpty) return; + // Cancel any pending debounce since we're searching immediately. + _debounceTimer?.cancel(); + // Move the query to the most recent result (top of the stack). _searchHistory.remove(query); _searchHistory.insert(0, query); + _performSearch(query); + } + + /// Performs the actual search API calls. + @action + void _performSearch(String query) { + // Futures are now pending, so isSearching can be cleared. + _isSearching = false; + // Fetch the matching channels, sort it by live status, and then set it. _channelFuture = twitchApi.searchChannels(query: query).then((channels) { channels.sort((c1, c2) => c2.isLive ? 1 : -1); @@ -96,6 +136,7 @@ abstract class SearchStoreBase with Store { } void dispose() { + _debounceTimer?.cancel(); textEditingController.dispose(); textFieldFocusNode.dispose(); } diff --git a/lib/screens/home/search/search_store.g.dart b/lib/screens/home/search/search_store.g.dart index bb576710..ace3f885 100644 --- a/lib/screens/home/search/search_store.g.dart +++ b/lib/screens/home/search/search_store.g.dart @@ -49,6 +49,26 @@ mixin _$SearchStore on SearchStoreBase, Store { }); } + late final _$_isSearchingAtom = Atom( + name: 'SearchStoreBase._isSearching', + context: context, + ); + + bool get isSearching { + _$_isSearchingAtom.reportRead(); + return super._isSearching; + } + + @override + bool get _isSearching => isSearching; + + @override + set _isSearching(bool value) { + _$_isSearchingAtom.reportWrite(value, super._isSearching, () { + super._isSearching = value; + }); + } + late final _$_channelFutureAtom = Atom( name: 'SearchStoreBase._channelFuture', context: context, @@ -104,6 +124,18 @@ mixin _$SearchStore on SearchStoreBase, Store { context: context, ); + @override + void onSearchTextChanged(String query) { + final _$actionInfo = _$SearchStoreBaseActionController.startAction( + name: 'SearchStoreBase.onSearchTextChanged', + ); + try { + return super.onSearchTextChanged(query); + } finally { + _$SearchStoreBaseActionController.endAction(_$actionInfo); + } + } + @override void handleQuery(String query) { final _$actionInfo = _$SearchStoreBaseActionController.startAction( @@ -116,6 +148,18 @@ mixin _$SearchStore on SearchStoreBase, Store { } } + @override + void _performSearch(String query) { + final _$actionInfo = _$SearchStoreBaseActionController.startAction( + name: 'SearchStoreBase._performSearch', + ); + try { + return super._performSearch(query); + } finally { + _$SearchStoreBaseActionController.endAction(_$actionInfo); + } + } + @override String toString() { return ''' From 359509ec52310328bd948d1102928481c44ca353 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 19:51:58 -0500 Subject: [PATCH 008/172] refactor chat interaction handling, improve gesture detection, restore chat delay input indicator --- lib/screens/channel/chat/chat.dart | 131 ++++++++-------- .../channel/chat/widgets/chat_bottom_bar.dart | 141 +++++++++--------- .../channel/chat/widgets/chat_tabs.dart | 17 ++- 3 files changed, 151 insertions(+), 138 deletions(-) diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 165f6274..944ba3d7 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -31,18 +31,21 @@ class Chat extends StatelessWidget { return Column( children: [ Expanded( - child: GestureDetector( - onTap: () { - if (chatStore.assetsStore.showEmoteMenu) { - chatStore.assetsStore.showEmoteMenu = false; - } else if (chatStore.textFieldFocusNode.hasFocus) { - chatStore.unfocusInput(); - } - }, - child: Stack( - alignment: AlignmentDirectional.bottomCenter, - children: [ - MediaQuery( + child: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + // Wrap only the message list with GestureDetector + // so taps on ChatBottomBar don't trigger unfocus + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (chatStore.assetsStore.showEmoteMenu) { + chatStore.assetsStore.showEmoteMenu = false; + } else if (chatStore.textFieldFocusNode.hasFocus) { + chatStore.unfocusInput(); + } + }, + child: MediaQuery( data: MediaQuery.of(context).copyWith( textScaler: chatStore.settings.messageScale.textScaler, ), @@ -93,68 +96,66 @@ class Chat extends StatelessWidget { ), ), ), - // Prevents accidental chat scrolling when swiping down from the top edge - // to access system UI (Notification Center/Control Center) in landscape mode. - if (context.isLandscape) - Positioned( - top: 0, - left: 0, - right: 0, - height: 24, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onVerticalDragStart: (_) {}, - ), - ), + ), + // Prevents accidental chat scrolling when swiping down from the top edge + // to access system UI (Notification Center/Control Center) in landscape mode. + if (context.isLandscape) Positioned( + top: 0, left: 0, right: 0, - bottom: 0, - child: ChatBottomBar( - chatStore: chatStore, - onAddChat: onAddChat, + height: 24, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragStart: (_) {}, ), ), - AnimatedPadding( - duration: const Duration(milliseconds: 200), - padding: EdgeInsets.only( - left: 4, - top: 4, - right: 4, - bottom: - 68 + - (chatStore.assetsStore.showEmoteMenu - ? 0 - : MediaQuery.of(context).padding.bottom), - ), - child: Observer( - builder: (_) => AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - child: chatStore.autoScroll - ? null - : ElevatedButton.icon( - onPressed: chatStore.resumeScroll, - 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(), - ], - ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ChatBottomBar( + chatStore: chatStore, + onAddChat: onAddChat, + ), + ), + AnimatedPadding( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.only( + left: 4, + top: 4, + right: 4, + bottom: + 68 + + (chatStore.assetsStore.showEmoteMenu + ? 0 + : MediaQuery.of(context).padding.bottom), + ), + child: Observer( + builder: (_) => AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: chatStore.autoScroll + ? null + : ElevatedButton.icon( + onPressed: chatStore.resumeScroll, + 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(), + ], ), ), - ), + ), ), ), - ], - ), + ), + ], ), ), AnimatedContainer( diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index ff20e7d3..4150c701 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -84,10 +84,10 @@ class ChatBottomBar extends StatelessWidget { final isFullscreenOverlay = chatStore.settings.fullScreen && context.isLandscape; + // Check if chat delay is active (for indicator only, doesn't block input) final hasChatDelay = chatStore.settings.showVideo && chatStore.settings.chatDelay > 0; - final delayTooltipMessage = - 'Chatting is disabled due to message delay (${chatStore.settings.chatDelay.toInt()}s)'; + const loginTooltipMessage = 'Log in to chat'; final bottomBarContent = Column( @@ -128,40 +128,44 @@ class ChatBottomBar extends StatelessWidget { ), ), ], + // Wrap autocomplete sections with TextFieldTapRegion so taps + // on them don't trigger TextField's onTapOutside callback. if (chatStore.settings.autocomplete && chatStore.showEmoteAutocomplete && matchingEmotes.isNotEmpty) ...[ const Divider(), - SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.all(4), - itemCount: matchingEmotes.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) => InkWell( - onTap: () => chatStore.addEmote( - matchingEmotes[index], - autocompleteMode: true, - ), - onLongPress: () { - HapticFeedback.lightImpact(); + TextFieldTapRegion( + child: SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.all(4), + itemCount: matchingEmotes.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => InkWell( + onTap: () => chatStore.addEmote( + matchingEmotes[index], + autocompleteMode: true, + ), + onLongPress: () { + HapticFeedback.lightImpact(); - IRCMessage.showEmoteDetailsBottomSheet( - context, - emote: matchingEmotes[index], - launchExternal: chatStore.settings.launchUrlExternal, - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Center( - child: FrostyCachedNetworkImage( - imageUrl: matchingEmotes[index].url, - useFade: false, - height: - matchingEmotes[index].height?.toDouble() ?? - defaultEmoteSize, - width: matchingEmotes[index].width?.toDouble(), + IRCMessage.showEmoteDetailsBottomSheet( + context, + emote: matchingEmotes[index], + launchExternal: chatStore.settings.launchUrlExternal, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: FrostyCachedNetworkImage( + imageUrl: matchingEmotes[index].url, + useFade: false, + height: + matchingEmotes[index].height?.toDouble() ?? + defaultEmoteSize, + width: matchingEmotes[index].width?.toDouble(), + ), ), ), ), @@ -173,30 +177,32 @@ class ChatBottomBar extends StatelessWidget { chatStore.showMentionAutocomplete && matchingChatters.isNotEmpty) ...[ const Divider(), - SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.all(4), - itemCount: matchingChatters.length, - scrollDirection: Axis.horizontal, - itemBuilder: (context, index) => TextButton( - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8), - ), - onPressed: () { - final split = chatStore.textController.text.split(' ') - ..removeLast() - ..add('@${matchingChatters[index]} '); + TextFieldTapRegion( + child: SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.all(4), + itemCount: matchingChatters.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + onPressed: () { + final split = chatStore.textController.text.split(' ') + ..removeLast() + ..add('@${matchingChatters[index]} '); - chatStore.textController.text = split.join(' '); - chatStore.textController.selection = - TextSelection.fromPosition( - TextPosition( - offset: chatStore.textController.text.length, - ), - ); - }, - child: Text(matchingChatters[index]), + chatStore.textController.text = split.join(' '); + chatStore.textController.selection = + TextSelection.fromPosition( + TextPosition( + offset: chatStore.textController.text.length, + ), + ); + }, + child: Text(matchingChatters[index]), + ), ), ), ), @@ -211,16 +217,14 @@ class ChatBottomBar extends StatelessWidget { context.isLandscape) Builder( builder: (context) { - final isDisabled = - !chatStore.auth.isLoggedIn || hasChatDelay; + final isDisabled = !chatStore.auth.isLoggedIn; return GestureDetector( onTap: isDisabled ? () { - final message = hasChatDelay - ? delayTooltipMessage - : loginTooltipMessage; - chatStore.updateNotification(message); + chatStore.updateNotification( + loginTooltipMessage, + ); } : null, child: IconButton( @@ -242,16 +246,14 @@ class ChatBottomBar extends StatelessWidget { builder: (context) { final isDisabled = !chatStore.auth.isLoggedIn || - chatStore.isSendingMessage || - hasChatDelay; + chatStore.isSendingMessage; return GestureDetector( onTap: isDisabled && !chatStore.isSendingMessage ? () { - final message = hasChatDelay - ? delayTooltipMessage - : loginTooltipMessage; - chatStore.updateNotification(message); + chatStore.updateNotification( + loginTooltipMessage, + ); } : null, child: TextField( @@ -259,7 +261,7 @@ class ChatBottomBar extends StatelessWidget { focusNode: chatStore.textFieldFocusNode, minLines: 1, maxLines: 3, - // Disable text field when sending message, when not logged in, or when chat delay is active + // Disable text field when sending message or when not logged in enabled: !isDisabled, decoration: InputDecoration( prefixIcon: @@ -283,6 +285,8 @@ class ChatBottomBar extends StatelessWidget { ? 'Sending...' : chatStore.replyingToMessage != null ? 'Reply' + : hasChatDelay + ? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)' : 'Chat' : loginTooltipMessage, ), @@ -317,8 +321,7 @@ class ChatBottomBar extends StatelessWidget { : const Icon(Icons.send_rounded), onPressed: chatStore.auth.isLoggedIn && - !chatStore.isSendingMessage && - !hasChatDelay + !chatStore.isSendingMessage ? () => chatStore.sendMessage( chatStore.textController.text, ) diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index 274fe808..3f81faff 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -74,14 +74,23 @@ class ChatTabs extends StatelessWidget { onPopInvokedWithResult: (didPop, _) { if (didPop) return; - // If pressing the back button on Android while the emote menu - // is open, close it instead of going back to the streams list. + // On Android back gesture, close overlays first before navigating back final activeStore = chatTabsStore.activeChatStore; + + // Priority 1: Close emote menu if (activeStore.assetsStore.showEmoteMenu) { activeStore.assetsStore.showEmoteMenu = false; - } else { - Navigator.of(context).pop(); + return; } + + // Priority 2: Unfocus keyboard + if (activeStore.textFieldFocusNode.hasFocus) { + activeStore.unfocusInput(); + return; + } + + // Priority 3: Navigate back + Navigator.of(context).pop(); }, child: Stack( children: [ From 23fee7bd4e5873c7248c7e5ead30abca6c636c80 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 21:51:50 -0500 Subject: [PATCH 009/172] add inline emote support in chat text field --- .../channel/chat/widgets/chat_bottom_bar.dart | 11 +- .../chat_input/emote_text_span_builder.dart | 122 ++++++++++++++++++ pubspec.lock | 16 +++ pubspec.yaml | 1 + 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/chat_input/emote_text_span_builder.dart diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 4150c701..54bf5c64 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -1,3 +1,4 @@ +import 'package:extended_text_field/extended_text_field.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -8,6 +9,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; class ChatBottomBar extends StatelessWidget { @@ -256,13 +258,20 @@ class ChatBottomBar extends StatelessWidget { ); } : null, - child: TextField( + child: ExtendedTextField( textInputAction: TextInputAction.send, focusNode: chatStore.textFieldFocusNode, minLines: 1, maxLines: 3, // Disable text field when sending message or when not logged in enabled: !isDisabled, + specialTextSpanBuilder: EmoteTextSpanBuilder( + emoteToObject: chatStore.assetsStore.emoteToObject, + userEmoteToObject: + chatStore.assetsStore.userEmoteToObject, + emoteSize: + chatStore.settings.emoteScale * defaultEmoteSize, + ), decoration: InputDecoration( prefixIcon: chatStore.settings.emoteMenuButtonOnLeft diff --git a/lib/widgets/chat_input/emote_text_span_builder.dart b/lib/widgets/chat_input/emote_text_span_builder.dart new file mode 100644 index 00000000..785140ff --- /dev/null +++ b/lib/widgets/chat_input/emote_text_span_builder.dart @@ -0,0 +1,122 @@ +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; + +/// A [SpecialTextSpanBuilder] that renders emote names as inline images. +/// +/// This builder parses input text word-by-word and converts recognized emote +/// names into [ExtendedWidgetSpan] widgets displaying the emote image. +/// Unrecognized words remain as regular text. +class EmoteTextSpanBuilder extends SpecialTextSpanBuilder { + /// Map of emote names to their [Emote] objects (global + channel emotes). + final Map emoteToObject; + + /// Map of user-owned emotes (subscriber, bits, unlocked). + final Map userEmoteToObject; + + /// The height to render emotes at. + final double emoteSize; + + EmoteTextSpanBuilder({ + required this.emoteToObject, + required this.userEmoteToObject, + required this.emoteSize, + }); + + @override + TextSpan build( + String data, { + TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, + }) { + if (data.isEmpty) { + return TextSpan(text: '', style: textStyle); + } + + final spans = []; + var currentIndex = 0; + + // Split by spaces but preserve space positions + final parts = _splitPreservingSpaces(data); + + for (final part in parts) { + if (part == ' ') { + // Add space as regular text + spans.add(TextSpan(text: ' ', style: textStyle)); + currentIndex += 1; + } else if (part.isNotEmpty) { + // Check if this word is an emote - render immediately on exact match + final emote = emoteToObject[part] ?? userEmoteToObject[part]; + + if (emote != null) { + spans.add(_createEmoteSpan(emote, part, currentIndex)); + } else { + spans.add(TextSpan(text: part, style: textStyle)); + } + currentIndex += part.length; + } + } + + return TextSpan(children: spans, style: textStyle); + } + + /// Splits text into words and spaces, preserving the spaces as separate elements. + List _splitPreservingSpaces(String text) { + final result = []; + final buffer = StringBuffer(); + + for (var i = 0; i < text.length; i++) { + final char = text[i]; + if (char == ' ') { + if (buffer.isNotEmpty) { + result.add(buffer.toString()); + buffer.clear(); + } + result.add(' '); + } else { + buffer.write(char); + } + } + + if (buffer.isNotEmpty) { + result.add(buffer.toString()); + } + + return result; + } + + /// Creates an [ExtendedWidgetSpan] for the given emote. + ExtendedWidgetSpan _createEmoteSpan(Emote emote, String text, int start) { + // Calculate dimensions - use emote's native size if available, + // otherwise use the configured emoteSize + final height = emote.height?.toDouble() ?? emoteSize; + final width = emote.width?.toDouble(); + + return ExtendedWidgetSpan( + actualText: text, + start: start, + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: FrostyCachedNetworkImage( + imageUrl: emote.url, + height: height, + width: width, + useFade: false, + ), + ), + ); + } + + @override + SpecialText? createSpecialText( + String flag, { + TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, + int? index, + }) { + // We handle all parsing in the build method, so this returns null + return null; + } +} diff --git a/pubspec.lock b/pubspec.lock index ec8c083a..19197629 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -305,6 +305,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + extended_text_field: + dependency: "direct main" + description: + name: extended_text_field + sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" + url: "https://pub.dev" + source: hosted + version: "16.0.2" + extended_text_library: + dependency: transitive + description: + name: extended_text_library + sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" + url: "https://pub.dev" + source: hosted + version: "12.0.1" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 71604401..661a57d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: path_provider: ^2.1.5 dio: ^5.9.0 app_links: ^7.0.0 + extended_text_field: ^16.0.2 dev_dependencies: flutter_test: From a996c51ae35168209bd1ec42c7c5bde81fc264dd Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Fri, 2 Jan 2026 22:25:26 -0500 Subject: [PATCH 010/172] update claude.md --- CLAUDE.md | 123 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 80 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 883cbe35..40caf127 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,34 +15,76 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Search the codebase to find existing usages and implementations - Prefer following established patterns, styles, and practices for consistency +## Skills & Plugins + +Claude Code has access to specialized skills. Use them proactively based on context: + +**`/feature-dev:feature-dev`** — Guided Feature Development +- Use when: Planning or implementing new features, analyzing architecture, creating implementation blueprints +- Trigger phrases: "add a feature", "implement", "build", "create new functionality" + +**`/frontend-design:frontend-design`** — Production UI Components +- Use when: Creating or improving UI components, widgets, screens, or visual design +- Trigger phrases: "design", "build UI", "create a widget", "improve the interface" + +**`/code-review:code-review`** — Code Quality Review +- Use when: After writing or modifying code, before commits, reviewing changes +- Trigger phrases: "review my code", "check this", "is this good?" +- **Proactive**: Suggest after significant code modifications + +**`/pr-review-toolkit:review-pr`** — Comprehensive PR Review +- Use when: Before creating PRs, reviewing PR changes, final quality checks +- Trigger phrases: "review the PR", "check before merging", "PR ready?" + +### Automatic Triggers + +Claude should proactively suggest skills in these situations: + +| After This Action | Suggest This Skill | +|-------------------|-------------------| +| Writing/editing multiple files | `/code-review:code-review` | +| Completing a feature implementation | `/code-review:code-review` | +| User says "ready for PR" or "create PR" | `/pr-review-toolkit:review-pr` | +| Discussing new feature requirements | `/feature-dev:feature-dev` | +| Building UI components or screens | `/frontend-design:frontend-design` | + +### Specialized Subagents + +These are advanced capabilities Claude can use for deeper analysis (not slash commands, but can be requested): + +**Code Quality & Review:** +- **Silent Failure Hunter** — Finds silent failures, poor error handling, swallowed exceptions + - Trigger: "hunt for silent failures", "check error handling", "find swallowed errors" +- **Code Simplifier** — Simplifies code while preserving functionality + - Trigger: "simplify this code", "make this cleaner", "reduce complexity" +- **Comment Analyzer** — Checks comments for accuracy and technical debt + - Trigger: "review the comments", "check documentation accuracy" + +**Architecture & Types:** +- **Type Design Analyzer** — Analyzes type design, encapsulation, and invariants + - Trigger: "analyze type design", "review this type", "check encapsulation" +- **Code Architect** — Designs feature architectures with implementation blueprints + - Trigger: "design the architecture", "create implementation blueprint" +- **Code Explorer** — Deep analysis of existing features, traces execution paths + - Trigger: "trace this flow", "how does this work end-to-end", "analyze this feature" + +**Testing:** +- **PR Test Analyzer** — Reviews test coverage quality and identifies gaps + - Trigger: "analyze test coverage", "are tests thorough enough", "find test gaps" + ## Development Commands -**Build and Development** - +**Common Commands:** - `flutter pub get` - Install dependencies -- `flutter run` - Run the app on connected device/emulator -- `flutter build apk` - Build Android APK -- `flutter build ios` - Build iOS app +- `flutter run --dart-define=clientId=YOUR_CLIENT_ID --dart-define=secret=YOUR_CLIENT_SECRET` - Run with Twitch API credentials - `flutter analyze` - Run static analysis and lint checks -- `flutter test` - Run tests (note: no test suite currently exists) +- `flutter test` - Run all tests +- `flutter build apk` / `flutter build ios` - Build release binaries -**Code Generation (Required for MobX stores and JSON serialization)** - -- `flutter packages pub run build_runner build` - Generate code once -- `flutter packages pub run build_runner watch` - Watch and regenerate code on changes -- `flutter packages pub run build_runner build --delete-conflicting-outputs` - Force rebuild all generated files - -**Asset Generation** - -- `flutter pub run flutter_native_splash:create` - Generate native splash screens -- `flutter pub run flutter_launcher_icons:main` - Generate app launcher icons - -**Environment Variables** -Use `--dart-define` to set environment variables (required for Twitch API access): - -```bash -flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YOUR_TWITCH_CLIENT_SECRET -``` +**Code Generation** (required after changing MobX stores or `@JsonSerializable` models): +- `flutter packages pub run build_runner build` - Generate once +- `flutter packages pub run build_runner build --delete-conflicting-outputs` - Force rebuild all +- `flutter packages pub run build_runner watch` - Watch mode for development ## Architecture Overview @@ -67,25 +109,10 @@ flutter run --dart-define=clientId=YOUR_TWITCH_CLIENT_ID --dart-define=secret=YO **Global Stores** (provided at app root in `main.dart`): - `AuthStore` - Authentication state and token management -- `SettingsStore` - User preferences with automatic persistence +- `SettingsStore` - User preferences with automatic persistence via MobX `autorun()` +- `GlobalAssetsStore` - Shared cache for global emotes and badges across all chat tabs -## HTTP Client Architecture - -**Centralized Dio Setup**: All API services share a single Dio instance configured in `DioClient.createClient()` with connection pooling, keep-alive headers, and optimized timeouts (8s connect, 15s receive, 10s send). - -**Authentication Interceptor**: `TwitchAuthInterceptor` automatically injects auth headers for Twitch API URLs. Updates automatically when user authentication changes. - -**API Service Pattern**: All API services extend `BaseApiClient` for consistent error handling. Services: `TwitchApi`, `BTTVApi`, `FFZApi`, `SevenTVApi`. - -## Authentication & Token Management - -**Two-tier Token System**: - -- Default app token for unauthenticated requests -- Optional user token stored in Flutter Secure Storage -- Automatic token validation on startup with fallback - -**Secure Storage Cleanup**: First-run detection clears secure storage to handle Android/iOS uninstall scenarios where secure storage persists. +**HTTP & Auth**: Centralized Dio client (`DioClient.createClient()`) with connection pooling and optimized timeouts. `TwitchAuthInterceptor` auto-injects auth headers for Twitch API URLs. `UnauthorizedInterceptor` catches 401 errors for token refresh. Two-tier token system: default app token + optional user token in Flutter Secure Storage. ## MobX Store Implementation @@ -113,7 +140,14 @@ After any changes to MobX stores or `@JsonSerializable()` models, run the build_ - **Real-time IRC**: WebSocket connection to Twitch IRC with custom `IRCMessage` parsing - **Third-party Emotes**: Asynchronous loading of BTTV, FFZ, and 7TV assets via dedicated APIs - **Message Management**: 5000 message limit with 20% batch removal optimization -- **Assets Store**: Separate `ChatAssetsStore` manages emotes, badges, and chat-related data +- **Assets Store**: Separate `ChatAssetsStore` manages channel-specific emotes/badges; `GlobalAssetsStore` caches global assets + +## Deep Linking + +App supports Twitch channel deep links (e.g., `twitch.tv/channelname`). Deep link handling in `main.dart`: +- Uses `app_links` package for URI stream handling +- Resolves channel names via Twitch API before navigation +- Graceful fallback with in-app browser option on failure ## Code Style @@ -122,11 +156,14 @@ Analysis rules enforced via `analysis_options.yaml`: - `prefer_single_quotes` - Use single quotes for strings - `always_use_package_imports` - Package imports over relative imports - `require_trailing_commas` - Trailing commas on multi-line constructs -- `prefer_final_locals` - Final for local variables +- `prefer_final_locals`, `prefer_final_in_for_each` - Prefer final variables - `directives_ordering`, `avoid_void_async`, `always_declare_return_types` +- `avoid_redundant_argument_values`, `unnecessary_parenthesis` Generated `.g.dart` files are excluded from analysis but must be committed. +**Error Handling**: Use `Future.error()` for API failures rather than throwing exceptions. + ## Commit Convention Commits follow conventional format: `refactor:`, `feat:`, `fix:`, `format:`, etc. Keep commits tightly scoped. Include generated `.g.dart` outputs in the same commit as their source changes. From cfc0e6187c8e7c6e072e091900d67669064d6061 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 4 Jan 2026 04:04:20 -0500 Subject: [PATCH 011/172] tweak padding --- lib/screens/home/search/search.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index adcc8bf0..00c9d256 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -147,7 +147,7 @@ class _SearchState extends State { isFirst: true, padding: EdgeInsets.fromLTRB( 16 + MediaQuery.of(context).padding.left, - 12, + 16, 16 + MediaQuery.of(context).padding.right, 8, ), From 1c712659f1f87587e73022b5bdbea3de09c528df Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 4 Jan 2026 16:14:53 -0500 Subject: [PATCH 012/172] fix black screen when logging in --- lib/screens/home/home.dart | 55 ++++++++++++--------- lib/screens/settings/stores/auth_store.dart | 3 +- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 3d97b3ef..2081dbbd 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -59,7 +59,6 @@ class _HomeState extends State { Widget build(BuildContext context) { SystemChrome.setPreferredOrientations([]); - final isLoggedIn = _authStore.isLoggedIn && _authStore.user.details != null; final theme = Theme.of(context); return GestureDetector( @@ -82,11 +81,10 @@ class _HomeState extends State { ), flexibleSpace: Observer( builder: (_) { - // Only show flexible space on Following tab + // Only show flexible space on Following tab (when logged in) final isOnFollowingTab = - isLoggedIn && _homeStore.selectedIndex == 0; + _authStore.isLoggedIn && _homeStore.selectedIndex == 0; - // Only show flexible space when on Following tab if (!isOnFollowingTab) return const SizedBox.shrink(); return BlurredContainer( @@ -107,24 +105,31 @@ class _HomeState extends State { }, ), actions: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - tooltip: 'Settings', - icon: isLoggedIn - ? ProfilePicture( - userLogin: _authStore.user.details!.login, - radius: 16, - ) - : const Icon(Icons.settings_rounded), - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - Settings(settingsStore: context.read()), + Observer( + builder: (_) { + final isLoggedIn = + _authStore.isLoggedIn && _authStore.user.details != null; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + tooltip: 'Settings', + icon: isLoggedIn + ? ProfilePicture( + userLogin: _authStore.user.details!.login, + radius: 16, + ) + : const Icon(Icons.settings_rounded), + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Settings( + settingsStore: context.read(), + ), + ), + ), ), - ), - ), + ); + }, ), ], ), @@ -174,7 +179,9 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.arrow_upward_rounded, - color: _homeStore.selectedIndex == (isLoggedIn ? 1 : 0) + color: + _homeStore.selectedIndex == + (_authStore.isLoggedIn ? 1 : 0) ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant.withValues( alpha: 0.6, @@ -190,7 +197,9 @@ class _HomeState extends State { NavigationDestination( icon: Icon( Icons.search_rounded, - color: _homeStore.selectedIndex == (isLoggedIn ? 2 : 1) + color: + _homeStore.selectedIndex == + (_authStore.isLoggedIn ? 2 : 1) ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant.withValues( alpha: 0.6, diff --git a/lib/screens/settings/stores/auth_store.dart b/lib/screens/settings/stores/auth_store.dart index ed97e8ee..a8e9f16c 100644 --- a/lib/screens/settings/stores/auth_store.dart +++ b/lib/screens/settings/stores/auth_store.dart @@ -87,8 +87,7 @@ abstract class AuthBase with Store { MaterialPageRoute(builder: (context) => routeAfter), ); } else { - // Pop twice, once to dismiss the WebView and again to dismiss the Login dialog. - navigatorKey.currentState?.pop(); + // Pop the WebView to return to the previous screen navigatorKey.currentState?.pop(); } } From fc56c3347a42f0ba153da64e63a083dd1de6b168 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 4 Jan 2026 16:35:43 -0500 Subject: [PATCH 013/172] fix clear recent emotes button sizing --- .../chat/emote_menu/recent_emotes_panel.dart | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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 fc6d0165..9bd71aa1 100644 --- a/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart +++ b/lib/screens/channel/chat/emote_menu/recent_emotes_panel.dart @@ -114,13 +114,19 @@ class RecentEmotesPanel extends StatelessWidget { ), SliverFillRemaining( hasScrollBody: false, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: TextButton.icon( - onPressed: () => _showClearDialog(context), - icon: const Icon(Icons.clear_all_rounded), - label: const Text('Clear recent emotes'), + child: Align( + alignment: Alignment.bottomCenter, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: SizedBox( + width: double.infinity, + child: TextButton.icon( + onPressed: () => _showClearDialog(context), + icon: const Icon(Icons.clear_all_rounded), + label: const Text('Clear recent emotes'), + ), + ), ), ), ), From bd194e91c27731035e7ea7ce304f2de7b3b667cf Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 4 Jan 2026 17:49:44 -0500 Subject: [PATCH 014/172] fix chat delay message sending ux refactor chat store to bypass delay for specific IRC commands and remove sending state management --- .../channel/chat/stores/chat_store.dart | 136 +++++++++--------- .../channel/chat/stores/chat_store.g.dart | 20 --- .../channel/chat/widgets/chat_bottom_bar.dart | 55 +++---- 3 files changed, 83 insertions(+), 128 deletions(-) diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 0d418745..b46e6eb2 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -28,6 +28,28 @@ abstract class ChatStoreBase with Store { /// The maximum ammount of messages to render when autoscroll is enabled. static const _renderMessageLimit = 100; + /// IRC commands that should bypass chat delay. + /// These are confirmations/state updates rather than chat messages to sync. + static const _delayBypassCommands = { + 'USERSTATE', + 'GLOBALUSERSTATE', + 'ROOMSTATE', + }; + + /// Checks if IRC data contains a command that should bypass the chat delay. + /// Returns true if any message in the data contains a bypass command. + bool _shouldBypassDelay(String data) { + for (final message in data.trimRight().split('\r\n')) { + if (message.startsWith('@')) { + final parts = message.split(' '); + if (parts.length >= 3 && _delayBypassCommands.contains(parts[2])) { + return true; + } + } + } + return false; + } + final TwitchApi twitchApi; /// The amount of messages to free (remove) when the [_messageLimit] is reached. @@ -108,9 +130,6 @@ abstract class ChatStoreBase with Store { /// Timer used for dismissing the notification. Timer? _notificationTimer; - /// Timer used for resetting the sending state if no USERSTATE is received. - Timer? _sendingTimeoutTimer; - /// Timer used for updating the chat delay countdown message. Timer? _chatDelayCountdownTimer; @@ -167,10 +186,6 @@ abstract class ChatStoreBase with Store { @readonly var _showMentionAutocomplete = false; - /// Whether a message is currently being sent and waiting for server confirmation. - @readonly - var _isSendingMessage = false; - /// Whether the chat is currently in shared chat mode (based on source-room-id tag presence). @readonly var _isInSharedChatMode = false; @@ -185,9 +200,6 @@ abstract class ChatStoreBase with Store { @observable IRCMessage? replyingToMessage; - /// Public getter for whether a message is currently being sent. - bool get isSendingMessage => _isSendingMessage; - /// Public getter for whether the chat is in shared chat mode. bool get isInSharedChatMode => _isInSharedChatMode; @@ -413,12 +425,8 @@ abstract class ChatStoreBase with Store { if (toSend != null && parsedIRCMessage.tags['id'] != null) { toSend!.tags['id'] = parsedIRCMessage.tags['id']!; - textController.clear(); messageBuffer.add(toSend!); toSend = null; - // Reset sending state since message was successfully sent - _sendingTimeoutTimer?.cancel(); - _isSendingMessage = false; } break; case Command.globalUserState: @@ -662,14 +670,19 @@ abstract class ChatStoreBase with Store { // Listen for new messages and forward them to the handler. _channelListener = _channel?.stream.listen( (data) { - if (!settings.showVideo || settings.chatDelay == 0) { - _handleIRCData(data.toString()); + final dataStr = data.toString(); + + // Process immediately if delay is disabled or command should bypass delay + if (!settings.showVideo || + settings.chatDelay == 0 || + _shouldBypassDelay(dataStr)) { + _handleIRCData(dataStr); } else { final capturedConnectionId = currentConnectionId; Future.delayed(Duration(seconds: settings.chatDelay.toInt()), () { // Only process if this is still the active connection if (capturedConnectionId == currentConnectionId) { - _handleIRCData(data.toString()); + _handleIRCData(dataStr); } }); } @@ -868,12 +881,6 @@ abstract class ChatStoreBase with Store { // Do not send if the message is blank/empty. if (message.isEmpty) return; - // Prevent sending multiple messages simultaneously - if (_isSendingMessage) { - updateNotification('Please wait, sending previous message...'); - return; - } - if (_channel == null || _channel?.closeCode != null) { messageBuffer.add( IRCMessage.createNotice( @@ -881,56 +888,46 @@ abstract class ChatStoreBase with Store { 'Cannot send message - chat is disconnected. Reconnecting...', ), ); - } else { - // Set sending state to true - _isSendingMessage = true; + return; + } - // Start a timeout timer to reset sending state if no USERSTATE is received - _sendingTimeoutTimer?.cancel(); - _sendingTimeoutTimer = Timer(const Duration(seconds: 10), () { - if (_isSendingMessage) { - _isSendingMessage = false; - updateNotification( - 'Message failed to send. Try again or check connection.', - ); - } - }); + // Send the message to the IRC chat room. + _channel?.sink.add( + '${replyingToMessage != null ? '@reply-parent-msg-id=${replyingToMessage!.tags['id']} ' : ''}PRIVMSG #$channelName :$message', + ); - // Send the message to the IRC chat room. - _channel?.sink.add( - '${replyingToMessage != null ? '@reply-parent-msg-id=${replyingToMessage!.tags['id']} ' : ''}PRIVMSG #$channelName :$message', - ); + // Clear text field immediately (optimistic UI) + textController.clear(); - // Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render. - var userStateString = _userState.raw; - if (userStateString != null) { - if (message.length > 3 && message.substring(0, 3) == '/me') { - userStateString += - ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; - } else { - userStateString += - ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; - } - - final userChatMessage = IRCMessage.fromString(userStateString); - userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); - if (auth.isLoggedIn && auth.user.details != null) { - userChatMessage.tags['user-id'] = auth.user.details!.id; - } - - if (replyingToMessage != null && - replyingToMessage!.tags['id'] != null) { - userChatMessage.tags['reply-parent-msg-id'] = - replyingToMessage!.tags['id']!; - userChatMessage.tags['reply-parent-display-name'] = - replyingToMessage!.tags['display-name']!; - userChatMessage.tags['reply-parent-msg-body'] = - replyingToMessage!.message!; - } - - replyingToMessage = null; - toSend = userChatMessage; + // Obtain the logged-in user's appearance in chat with USERSTATE and create the full message to render. + var userStateString = _userState.raw; + if (userStateString != null) { + if (message.length > 3 && message.substring(0, 3) == '/me') { + userStateString += + ' :\x01ACTION ${message.replaceRange(0, 3, '').trim()}\x01'; + } else { + userStateString += + ' :${replyingToMessage?.tags['display-name'] != null ? '@${replyingToMessage!.tags['display-name']} ' : ''}${message.trim()}'; } + + final userChatMessage = IRCMessage.fromString(userStateString); + userChatMessage.localEmotes?.addAll(assetsStore.userEmoteToObject); + if (auth.isLoggedIn && auth.user.details != null) { + userChatMessage.tags['user-id'] = auth.user.details!.id; + } + + if (replyingToMessage != null && + replyingToMessage!.tags['id'] != null) { + userChatMessage.tags['reply-parent-msg-id'] = + replyingToMessage!.tags['id']!; + userChatMessage.tags['reply-parent-display-name'] = + replyingToMessage!.tags['display-name']!; + userChatMessage.tags['reply-parent-msg-body'] = + replyingToMessage!.message!; + } + + replyingToMessage = null; + toSend = userChatMessage; } } @@ -1039,7 +1036,6 @@ abstract class ChatStoreBase with Store { _messageBufferTimer?.cancel(); _notificationTimer?.cancel(); - _sendingTimeoutTimer?.cancel(); _cancelChatDelayCountdown(); sleepTimer?.cancel(); diff --git a/lib/screens/channel/chat/stores/chat_store.g.dart b/lib/screens/channel/chat/stores/chat_store.g.dart index bf3d04d0..9292169e 100644 --- a/lib/screens/channel/chat/stores/chat_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_store.g.dart @@ -184,26 +184,6 @@ mixin _$ChatStore on ChatStoreBase, Store { ); } - late final _$_isSendingMessageAtom = Atom( - name: 'ChatStoreBase._isSendingMessage', - context: context, - ); - - bool get isSendingMessage { - _$_isSendingMessageAtom.reportRead(); - return super._isSendingMessage; - } - - @override - bool get _isSendingMessage => isSendingMessage; - - @override - set _isSendingMessage(bool value) { - _$_isSendingMessageAtom.reportWrite(value, super._isSendingMessage, () { - super._isSendingMessage = value; - }); - } - late final _$_isInSharedChatModeAtom = Atom( name: 'ChatStoreBase._isInSharedChatMode', context: context, diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 54bf5c64..e74ccd02 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -246,12 +246,10 @@ class ChatBottomBar extends StatelessWidget { Expanded( child: Observer( builder: (context) { - final isDisabled = - !chatStore.auth.isLoggedIn || - chatStore.isSendingMessage; + final isLoggedIn = chatStore.auth.isLoggedIn; return GestureDetector( - onTap: isDisabled && !chatStore.isSendingMessage + onTap: !isLoggedIn ? () { chatStore.updateNotification( loginTooltipMessage, @@ -263,8 +261,7 @@ class ChatBottomBar extends StatelessWidget { focusNode: chatStore.textFieldFocusNode, minLines: 1, maxLines: 3, - // Disable text field when sending message or when not logged in - enabled: !isDisabled, + enabled: isLoggedIn, specialTextSpanBuilder: EmoteTextSpanBuilder( emoteToObject: chatStore.assetsStore.emoteToObject, userEmoteToObject: @@ -289,14 +286,12 @@ class ChatBottomBar extends StatelessWidget { ], ), hintMaxLines: 1, - hintText: chatStore.auth.isLoggedIn - ? chatStore.isSendingMessage - ? 'Sending...' - : chatStore.replyingToMessage != null - ? 'Reply' - : hasChatDelay - ? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)' - : 'Chat' + hintText: isLoggedIn + ? chatStore.replyingToMessage != null + ? 'Reply' + : hasChatDelay + ? 'Chat (${chatStore.settings.chatDelay.toInt()}s delay)' + : 'Chat' : loginTooltipMessage, ), controller: chatStore.textController, @@ -313,30 +308,14 @@ class ChatBottomBar extends StatelessWidget { (chatStore.settings.chatWidth >= 0.3 || chatStore.expandChat || context.isPortrait)) - Observer( - builder: (context) { - return IconButton( - tooltip: chatStore.isSendingMessage - ? 'Sending...' - : 'Send', - icon: chatStore.isSendingMessage - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.send_rounded), - onPressed: - chatStore.auth.isLoggedIn && - !chatStore.isSendingMessage - ? () => chatStore.sendMessage( - chatStore.textController.text, - ) - : null, - ); - }, + IconButton( + tooltip: 'Send', + icon: const Icon(Icons.send_rounded), + onPressed: chatStore.auth.isLoggedIn + ? () => chatStore.sendMessage( + chatStore.textController.text, + ) + : null, ) else IconButton( From 4a9678bb152bb89f965a0f16f9bad938bb3eb374 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 4 Jan 2026 20:36:53 -0500 Subject: [PATCH 015/172] fix autocomplete bar blocking bottom of chat --- lib/screens/channel/chat/chat.dart | 6 +-- .../channel/chat/stores/chat_store.dart | 47 +++++++++++++++++++ .../channel/chat/stores/chat_store.g.dart | 28 ++++++++++- .../channel/chat/widgets/chat_bottom_bar.dart | 27 +---------- 4 files changed, 79 insertions(+), 29 deletions(-) diff --git a/lib/screens/channel/chat/chat.dart b/lib/screens/channel/chat/chat.dart index 944ba3d7..18ec8e3e 100644 --- a/lib/screens/channel/chat/chat.dart +++ b/lib/screens/channel/chat/chat.dart @@ -58,7 +58,7 @@ class Chat extends StatelessWidget { padding: EdgeInsets.only( top: MediaQuery.of(context).padding.top, bottom: - 68 + + chatStore.bottomBarHeight + (chatStore.assetsStore.showEmoteMenu ? 0 : MediaQuery.of(context).padding.bottom), @@ -70,7 +70,7 @@ class Chat extends StatelessWidget { padding: (listPadding ?? EdgeInsets.zero).add( EdgeInsets.only( bottom: - 68 + + chatStore.bottomBarHeight + (chatStore.assetsStore.showEmoteMenu ? 0 : MediaQuery.of( @@ -126,7 +126,7 @@ class Chat extends StatelessWidget { top: 4, right: 4, bottom: - 68 + + chatStore.bottomBarHeight + (chatStore.assetsStore.showEmoteMenu ? 0 : MediaQuery.of(context).padding.bottom), diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index b46e6eb2..da6268fb 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -36,6 +36,12 @@ abstract class ChatStoreBase with Store { 'ROOMSTATE', }; + /// Base height of the bottom bar (input field area). + static const _baseBottomBarHeight = 68.0; + + /// Height of the autocomplete bar (SizedBox + Divider). + static const _autocompleteHeight = 51.0; + /// Checks if IRC data contains a command that should bypass the chat delay. /// Returns true if any message in the data contains a bypass command. bool _shouldBypassDelay(String data) { @@ -203,6 +209,47 @@ abstract class ChatStoreBase with Store { /// Public getter for whether the chat is in shared chat mode. bool get isInSharedChatMode => _isInSharedChatMode; + /// Emotes matching the current autocomplete search term. + @computed + List get matchingEmotes { + if (!_showEmoteAutocomplete) return const []; + final searchTerm = _inputText.split(' ').last.toLowerCase(); + if (searchTerm.isEmpty) return const []; + + return [ + ...assetsStore.userEmoteToObject.values, + ...assetsStore.bttvEmotes, + ...assetsStore.ffzEmotes, + ...assetsStore.sevenTVEmotes, + ].where((emote) => emote.name.toLowerCase().contains(searchTerm)).toList(); + } + + /// Chatters matching the current mention autocomplete search term. + @computed + List get matchingChatters { + if (!_showMentionAutocomplete) return const []; + final searchTerm = + _inputText.split(' ').last.replaceFirst('@', '').toLowerCase(); + return chatDetailsStore.chatUsers + .where((chatter) => chatter.contains(searchTerm)) + .toList(); + } + + /// Current bottom bar height based on visible overlays. + /// Used to dynamically adjust chat message list padding. + @computed + double get bottomBarHeight { + var height = _baseBottomBarHeight; + + if (settings.autocomplete && + ((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) || + (_showMentionAutocomplete && matchingChatters.isNotEmpty))) { + height += _autocompleteHeight; + } + + return height; + } + ChatStoreBase({ required this.twitchApi, required this.auth, diff --git a/lib/screens/channel/chat/stores/chat_store.g.dart b/lib/screens/channel/chat/stores/chat_store.g.dart index 9292169e..7e956e78 100644 --- a/lib/screens/channel/chat/stores/chat_store.g.dart +++ b/lib/screens/channel/chat/stores/chat_store.g.dart @@ -17,6 +17,29 @@ mixin _$ChatStore on ChatStoreBase, Store { () => super.renderMessages, name: 'ChatStoreBase.renderMessages', )).value; + Computed>? _$matchingEmotesComputed; + + @override + List get matchingEmotes => + (_$matchingEmotesComputed ??= Computed>( + () => super.matchingEmotes, + name: 'ChatStoreBase.matchingEmotes', + )).value; + Computed>? _$matchingChattersComputed; + + @override + List get matchingChatters => + (_$matchingChattersComputed ??= Computed>( + () => super.matchingChatters, + name: 'ChatStoreBase.matchingChatters', + )).value; + Computed? _$bottomBarHeightComputed; + + @override + double get bottomBarHeight => (_$bottomBarHeightComputed ??= Computed( + () => super.bottomBarHeight, + name: 'ChatStoreBase.bottomBarHeight', + )).value; late final _$timeRemainingAtom = Atom( name: 'ChatStoreBase.timeRemaining', @@ -489,7 +512,10 @@ mixin _$ChatStore on ChatStoreBase, Store { timeRemaining: ${timeRemaining}, expandChat: ${expandChat}, replyingToMessage: ${replyingToMessage}, -renderMessages: ${renderMessages} +renderMessages: ${renderMessages}, +matchingEmotes: ${matchingEmotes}, +matchingChatters: ${matchingChatters}, +bottomBarHeight: ${bottomBarHeight} '''; } } diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index e74ccd02..dd0c9304 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -57,31 +57,8 @@ class ChatBottomBar extends StatelessWidget { return Observer( builder: (context) { - final matchingEmotes = - [ - ...chatStore.assetsStore.userEmoteToObject.values, - ...chatStore.assetsStore.bttvEmotes, - ...chatStore.assetsStore.ffzEmotes, - ...chatStore.assetsStore.sevenTVEmotes, - ] - .where( - (emote) => emote.name.toLowerCase().contains( - chatStore.inputText.split(' ').last.toLowerCase(), - ), - ) - .toList(); - - final matchingChatters = chatStore.chatDetailsStore.chatUsers - .where( - (chatter) => chatter.contains( - chatStore.inputText - .split(' ') - .last - .replaceFirst('@', '') - .toLowerCase(), - ), - ) - .toList(); + final matchingEmotes = chatStore.matchingEmotes; + final matchingChatters = chatStore.matchingChatters; final isFullscreenOverlay = chatStore.settings.fullScreen && context.isLandscape; From 08913c1e351ddd37c1ae76f2a6c686787a20f1dd Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 4 Jan 2026 21:36:00 -0500 Subject: [PATCH 016/172] fix reply bar blocking bottom of chat, redesign --- .../channel/chat/stores/chat_store.dart | 9 +++ .../channel/chat/widgets/chat_bottom_bar.dart | 76 ++++++++++++------- 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index da6268fb..b28e77cc 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -42,6 +42,9 @@ abstract class ChatStoreBase with Store { /// Height of the autocomplete bar (SizedBox + Divider). static const _autocompleteHeight = 51.0; + /// Height of the reply bar (Container + Divider). + static const _replyBarHeight = 41.0; + /// Checks if IRC data contains a command that should bypass the chat delay. /// Returns true if any message in the data contains a bypass command. bool _shouldBypassDelay(String data) { @@ -241,6 +244,12 @@ abstract class ChatStoreBase with Store { double get bottomBarHeight { var height = _baseBottomBarHeight; + // Add reply bar height when replying to a message. + if (replyingToMessage != null) { + height += _replyBarHeight; + } + + // Add autocomplete bar height when showing matches. if (settings.autocomplete && ((_showEmoteAutocomplete && matchingEmotes.isNotEmpty) || (_showMentionAutocomplete && matchingChatters.isNotEmpty))) { diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index dd0c9304..c262d54e 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -73,37 +73,57 @@ class ChatBottomBar extends StatelessWidget { children: [ if (chatStore.replyingToMessage != null) ...[ const Divider(), - ListTile( - contentPadding: const EdgeInsets.only(left: 16), - leading: const Icon(Icons.reply), - title: Tooltip( - message: chatStore.replyingToMessage!.message, - preferBelow: false, - child: Text.rich( - TextSpan( - children: chatStore.replyingToMessage!.generateSpan( - context, - assetsStore: chatStore.assetsStore, - emoteScale: chatStore.settings.emoteScale, - badgeScale: chatStore.settings.badgeScale, - launchExternal: chatStore.settings.launchUrlExternal, - timestamp: chatStore.settings.timestampType, - currentChannelId: chatStore.channelId, + TextFieldTapRegion( + child: Container( + height: 40, + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.secondary, + width: 4, ), ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: context.defaultTextStyle.copyWith( - fontSize: chatStore.settings.fontSize, - ), ), - ), - trailing: IconButton( - tooltip: 'Cancel reply', - onPressed: () { - chatStore.replyingToMessage = null; - }, - icon: const Icon(Icons.close), + // Left: 8px so text aligns at 12px (4px border + 8px padding) + // Right: 4px to give close button some breathing room + padding: const EdgeInsets.only(left: 8, right: 4), + alignment: Alignment.centerLeft, + child: Row( + children: [ + Expanded( + child: Tooltip( + message: chatStore.replyingToMessage!.message ?? '', + preferBelow: false, + child: Text.rich( + TextSpan( + children: + chatStore.replyingToMessage!.generateSpan( + context, + assetsStore: chatStore.assetsStore, + emoteScale: chatStore.settings.emoteScale, + badgeScale: chatStore.settings.badgeScale, + launchExternal: + chatStore.settings.launchUrlExternal, + timestamp: chatStore.settings.timestampType, + currentChannelId: chatStore.channelId, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.defaultTextStyle.copyWith( + fontSize: chatStore.settings.fontSize, + ), + ), + ), + ), + IconButton( + tooltip: 'Cancel reply', + visualDensity: VisualDensity.compact, + onPressed: () => chatStore.replyingToMessage = null, + icon: const Icon(Icons.close, size: 20), + ), + ], + ), ), ), ], From 04e076747b54821b87e9a88249a3cc09275dfdd1 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 4 Jan 2026 22:18:07 -0500 Subject: [PATCH 017/172] fix error message --- lib/screens/home/search/search_results_channels.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index f7413995..4bde37a7 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -57,11 +57,11 @@ class _SearchResultsChannelsState extends State { debugPrint('Search channels error: $error'); final snackBar = SnackBar( content: AlertMessage( - message: 'Unable to follow channel', + message: 'Unable to find channel', centered: false, ), ); - + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar(snackBar); } } From aaad50c10d8f2c789a997b890730169213cb3562 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Sun, 4 Jan 2026 22:21:22 -0500 Subject: [PATCH 018/172] release v5.0.2 --- assets/release-notes.md | 20 ++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/assets/release-notes.md b/assets/release-notes.md index 5d87fabe..26314658 100644 --- a/assets/release-notes.md +++ b/assets/release-notes.md @@ -1,3 +1,23 @@ +## v5.0.2 + +January 4, 2026 + +### Improvements + +- Inline emote support in the chat text field. +- Search page now uses type-to-search for faster navigation. +- Restored ability to send messages while chat delay is active. +- Restored chat delay input indicator. +- Redesigned the reply bar. + +### Bug Fixes + +- Fixed reply bar and autocomplete bar blocking the bottom of chat. +- Fixed clear recent emotes button sizing. +- Fixed black screen when logging in. + +--- + ## v5.0.1 January 2, 2026 diff --git a/pubspec.yaml b/pubspec.yaml index 661a57d3..c9c20fed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 5.0.1+64 +version: 5.0.2+65 environment: sdk: ^3.9.0 From 4d0b75a1c55b732ae1128d9d49651df08b8bfe6c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 5 Jan 2026 23:40:31 -0500 Subject: [PATCH 019/172] rename enhanced rendering setting to reset for all users --- lib/screens/channel/video/video.dart | 2 +- .../settings/stores/settings_store.dart | 8 +++--- .../settings/stores/settings_store.g.dart | 28 ++++++++----------- lib/screens/settings/video_settings.dart | 6 ++-- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/lib/screens/channel/video/video.dart b/lib/screens/channel/video/video.dart index 8d99146b..fa9cc703 100644 --- a/lib/screens/channel/video/video.dart +++ b/lib/screens/channel/video/video.dart @@ -47,7 +47,7 @@ class _VideoState extends State