diff --git a/lib/app.dart b/lib/app.dart index 77d6a3a622..c9d2eee8e8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -25,7 +25,6 @@ import 'ui/provider/mention_cache_provider.dart'; import 'ui/provider/multi_auth_provider.dart'; import 'ui/provider/setting_provider.dart'; import 'ui/provider/slide_category_provider.dart'; -import 'utils/app_lifecycle.dart'; import 'utils/extension/extension.dart'; import 'utils/logger.dart'; import 'utils/platform.dart'; @@ -134,75 +133,68 @@ class _App extends HookConsumerWidget { final Widget home; @override - Widget build(BuildContext context, WidgetRef ref) { - final appActive = useValueListenable(appActiveListener); - - return TickerMode( - enabled: appActive, - child: WindowShortcuts( - child: GlobalMoveWindow( - child: MaterialApp( - title: 'Mixin', - navigatorObservers: [rootRouteObserver], - debugShowCheckedModeBanner: false, - localizationsDelegates: const [ - Localization.delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: [...Localization.delegate.supportedLocales], - theme: ThemeData( - colorScheme: ColorScheme.light( - primary: lightBrightnessThemeData.text, - ), - textSelectionTheme: TextSelectionThemeData( - cursorColor: lightBrightnessThemeData.accent, - ), - useMaterial3: true, - ).withFallbackFonts(), - darkTheme: ThemeData( - colorScheme: ColorScheme.dark( - primary: darkBrightnessThemeData.text, - ), - textSelectionTheme: TextSelectionThemeData( - cursorColor: darkBrightnessThemeData.accent, + Widget build(BuildContext context, WidgetRef ref) => WindowShortcuts( + child: GlobalMoveWindow( + child: MaterialApp( + title: 'Mixin', + navigatorObservers: [rootRouteObserver], + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + Localization.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: [...Localization.delegate.supportedLocales], + theme: ThemeData( + colorScheme: ColorScheme.light( + primary: lightBrightnessThemeData.text, + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: lightBrightnessThemeData.accent, + ), + useMaterial3: true, + ).withFallbackFonts(), + darkTheme: ThemeData( + colorScheme: ColorScheme.dark( + primary: darkBrightnessThemeData.text, + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: darkBrightnessThemeData.accent, + ), + useMaterial3: true, + ).withFallbackFonts(), + themeMode: ref.watch(settingProvider).themeMode, + builder: (context, child) { + try { + context.accountServer.language = Localizations.localeOf( + context, + ).languageCode; + } catch (_) {} + final mediaQueryData = MediaQuery.of(context); + return BrightnessObserver( + lightThemeData: lightBrightnessThemeData, + darkThemeData: darkBrightnessThemeData, + forceBrightness: ref.watch(settingProvider).brightness, + child: MediaQuery( + data: mediaQueryData.copyWith( + // Different linux distro change the value, e.g. 1.2 + textScaler: Platform.isLinux + ? TextScaler.noScaling + : mediaQueryData.textScaler, ), - useMaterial3: true, - ).withFallbackFonts(), - themeMode: ref.watch(settingProvider).themeMode, - builder: (context, child) { - try { - context.accountServer.language = Localizations.localeOf( - context, - ).languageCode; - } catch (_) {} - final mediaQueryData = MediaQuery.of(context); - return BrightnessObserver( - lightThemeData: lightBrightnessThemeData, - darkThemeData: darkBrightnessThemeData, - forceBrightness: ref.watch(settingProvider).brightness, - child: MediaQuery( - data: mediaQueryData.copyWith( - // Different linux distro change the value, e.g. 1.2 - textScaler: Platform.isLinux - ? TextScaler.noScaling - : mediaQueryData.textScaler, - ), - child: SystemTrayWidget( - child: _WindowsTitleBarDivider( - child: AuthGuard(child: child!), - ), - ), + child: SystemTrayWidget( + child: _WindowsTitleBarDivider( + child: AuthGuard(child: child!), ), - ); - }, - home: MixinAppActions(child: MacosMenuBar(child: home)), - ), - ), + ), + ), + ); + }, + home: MixinAppActions(child: MacosMenuBar(child: home)), ), - ); - } + ), + ); } class _WindowsTitleBarDivider extends StatelessWidget { diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index c86d677440..b27e575f7c 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -84,7 +84,9 @@ class InputContainer extends HookConsumerWidget { child: LayoutBuilder( builder: (context, constraints) => VoiceRecorderBarOverlayComposition( layoutWidth: constraints.maxWidth, - child: const _InputContainer(), + child: const AppActiveTickerMode( + child: _InputContainer(), + ), ), ), ); diff --git a/lib/utils/app_lifecycle.dart b/lib/utils/app_lifecycle.dart index 4f627c9c2a..5c398f1b58 100644 --- a/lib/utils/app_lifecycle.dart +++ b/lib/utils/app_lifecycle.dart @@ -12,6 +12,20 @@ bool get isAppActive => _appObserver._isActive.value; ValueNotifier get appActiveListener => _appObserver._isActive; +class AppActiveTickerMode extends StatelessWidget { + const AppActiveTickerMode({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: appActiveListener, + builder: (context, enabled, child) => + TickerMode(enabled: enabled, child: child!), + child: child, + ); +} + class _AppLifecycleObserver extends WidgetsBindingObserver { final _isActive = ValueNotifier(true); diff --git a/lib/widgets/message/item/image/image_preview_page.dart b/lib/widgets/message/item/image/image_preview_page.dart index 13d6ab84a3..d582ab91d0 100644 --- a/lib/widgets/message/item/image/image_preview_page.dart +++ b/lib/widgets/message/item/image/image_preview_page.dart @@ -12,6 +12,7 @@ import 'package:rxdart/rxdart.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart' hide Offset; import '../../../../enum/message_category.dart'; +import '../../../../utils/app_lifecycle.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../utils/platform.dart'; import '../../../../utils/system/clipboard.dart'; @@ -604,25 +605,27 @@ class _Item extends HookConsumerWidget { child: DecoratedBox( decoration: const BoxDecoration(color: Color.fromRGBO(62, 65, 72, 0.9)), child: ClipRect( - child: ImagPreviewWidget( - scale: initialScale, - minScale: math.min(initialScale / 2, 0.5), - maxScale: math.max(initialScale * 2, 2), - controller: controller, - onEmptyAreaTapped: () { - Navigator.maybePop(context); - }, - image: Image.file( - File( - context.accountServer.convertMessageAbsolutePath( - message, - isTranscriptPage, + child: AppActiveTickerMode( + child: ImagPreviewWidget( + scale: initialScale, + minScale: math.min(initialScale / 2, 0.5), + maxScale: math.max(initialScale * 2, 2), + controller: controller, + onEmptyAreaTapped: () { + Navigator.maybePop(context); + }, + image: Image.file( + File( + context.accountServer.convertMessageAbsolutePath( + message, + isTranscriptPage, + ), ), - ), - fit: BoxFit.contain, - errorBuilder: (context, error, s) => ImageByBlurHashOrBase64( - imageData: message.thumbImage ?? '', fit: BoxFit.contain, + errorBuilder: (context, error, s) => ImageByBlurHashOrBase64( + imageData: message.thumbImage ?? '', + fit: BoxFit.contain, + ), ), ), ), diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index b13749bcc2..5a4a2e466f 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -17,6 +17,7 @@ import '../../ui/provider/message_selection_provider.dart'; import '../../ui/provider/quote_message_provider.dart'; import '../../ui/provider/setting_provider.dart'; import '../../ui/provider/user_cache_provider.dart'; +import '../../utils/app_lifecycle.dart'; import '../../utils/extension/extension.dart'; import '../avatar_view/avatar_view.dart'; import '../interactive_decorated_box.dart'; @@ -277,23 +278,25 @@ class MessageItemWidget extends HookConsumerWidget { child: child, ); - return FocusScope( - node: focusNode, - child: MessageContext( - isTranscriptPage: isTranscriptPage, - isPinnedPage: isPinnedPage, - showNip: presentation.showNip, - isCurrentUser: presentation.isCurrentUser, - message: message, - highlightEnabled: blink, - menuHighlighted: showedMenu.value, - child: Builder( - builder: (context) => MessageQuickReplyDetector( - child: Padding( - padding: row.sameUserPrev - ? EdgeInsets.zero - : const EdgeInsets.only(top: 8), - child: child, + return AppActiveTickerMode( + child: FocusScope( + node: focusNode, + child: MessageContext( + isTranscriptPage: isTranscriptPage, + isPinnedPage: isPinnedPage, + showNip: presentation.showNip, + isCurrentUser: presentation.isCurrentUser, + message: message, + highlightEnabled: blink, + menuHighlighted: showedMenu.value, + child: Builder( + builder: (context) => MessageQuickReplyDetector( + child: Padding( + padding: row.sameUserPrev + ? EdgeInsets.zero + : const EdgeInsets.only(top: 8), + child: child, + ), ), ), ), diff --git a/test/widgets/message/message_context_test.dart b/test/widgets/message/message_context_test.dart index 5b3dbd7418..11e098d052 100644 --- a/test/widgets/message/message_context_test.dart +++ b/test/widgets/message/message_context_test.dart @@ -3,12 +3,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_app/constants/brightness_theme_data.dart'; import 'package:flutter_app/db/mixin_database.dart' hide Offset; import 'package:flutter_app/enum/message_category.dart'; +import 'package:flutter_app/generated/l10n.dart'; import 'package:flutter_app/ui/home/notifier/blink_notifier.dart'; import 'package:flutter_app/ui/provider/database_provider.dart'; import 'package:flutter_app/ui/provider/mention_cache_provider.dart'; import 'package:flutter_app/ui/provider/message_selection_provider.dart'; import 'package:flutter_app/ui/provider/quote_message_provider.dart'; import 'package:flutter_app/ui/provider/setting_provider.dart'; +import 'package:flutter_app/utils/app_lifecycle.dart'; import 'package:flutter_app/utils/hook.dart'; import 'package:flutter_app/widgets/brightness_observer.dart'; import 'package:flutter_app/widgets/high_light_text.dart'; @@ -22,10 +24,12 @@ import 'package:flutter_app/widgets/message/message.dart'; import 'package:flutter_app/widgets/message/message_action_policy.dart'; import 'package:flutter_app/widgets/message/message_bubble.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide User; import 'package:provider/provider.dart' as provider; +import 'package:visibility_detector/visibility_detector.dart'; void main() { test('MessageRows links top tail to first bottom row without center', () { @@ -292,6 +296,106 @@ void main() { expect(quoteWidth, greaterThanOrEqualTo(bodyWidth)); }); + testWidgets( + 'inactive mutes message ticker while sibling scroll still animates', + (tester) async { + final previousAppActive = appActiveListener.value; + final visibilityController = VisibilityDetectorController.instance; + final previousUpdateInterval = visibilityController.updateInterval; + addTearDown( + () => visibilityController.updateInterval = previousUpdateInterval, + ); + visibilityController.updateInterval = Duration.zero; + addTearDown(() => appActiveListener.value = previousAppActive); + initAppLifecycleObserver(); + appActiveListener.value = true; + final message = testMessage('ticker', type: MessageCategory.secret); + + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + _MessageTestScope( + child: Localizations( + locale: const Locale('en'), + delegates: const [ + Localization.delegate, + GlobalWidgetsLocalizations.delegate, + ], + child: SizedBox( + width: 600, + height: 600, + child: Column( + children: [ + SizedBox( + height: 100, + child: MessageItemWidget( + message: message, + row: MessageRowModel(message: message, prev: message), + isGroupOrBotGroupConversation: false, + enableShowAvatar: false, + blink: false, + showUnreadBar: false, + ), + ), + Expanded( + child: ListView( + controller: scrollController, + children: List.generate( + 40, + (index) => SizedBox( + height: 40, + child: Text(index.toString()), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(MessageContext), findsOneWidget); + expect(scrollController.position.maxScrollExtent, greaterThan(0)); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await tester.pump(); + expect( + TickerMode.valuesOf( + tester.element(find.byType(MessageContext)), + ).enabled, + isFalse, + ); + expect( + TickerMode.valuesOf(tester.element(find.byType(Scrollable))).enabled, + isTrue, + ); + + final scrollAnimation = scrollController.animateTo( + 400, + duration: const Duration(seconds: 1), + curve: Curves.linear, + ); + await tester.pumpAndSettle(const Duration(milliseconds: 50)); + expect(scrollController.offset, greaterThan(0)); + await scrollAnimation; + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect( + TickerMode.valuesOf( + tester.element(find.byType(MessageContext)), + ).enabled, + isTrue, + ); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }, + ); testWidgets('current user blink is visible enough', (tester) async { final notifier = BlinkNotifier(tester)..blinkByMessageId('1');