From ff8f61423e30923d46f9e7122404a8d0715888f4 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 12 Sep 2026 07:25:37 +0800 Subject: [PATCH] fix(ui): refine app update dialog layout and scrolling (#1503) --- .../widgets/app_update_dialog.dart | 28 ++++----- .../widgets/app_update_notes_viewport.dart | 59 +++++++++++++++++++ ui/flutter/test/updater_test.dart | 39 ++++++++++++ 3 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart index 7367c609..35a29293 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_dialog.dart @@ -10,6 +10,7 @@ import '../../../../util/package_info.dart'; import '../../../../util/updater.dart'; import '../../../../util/util.dart'; import '../../../../l10n/l10n.dart'; +import 'app_update_notes_viewport.dart'; typedef StartAppUpdate = Future Function(VersionInfo versionInfo, UpdateProgressCallback onProgress); @@ -74,11 +75,18 @@ Future showAppUpdateDialog( key: const ValueKey('app-update-heading'), textAlign: TextAlign.center, ), - const SizedBox(height: 3), + const SizedBox(height: 8), Text( '$appVersion → ${versionInfo.version}', textAlign: TextAlign.center, - style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400), + style: DefaultTextStyle.of(dialogContext).style.copyWith( + inherit: false, + color: palette.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.4, + decoration: TextDecoration.none, + ), ), ], ), @@ -90,18 +98,9 @@ Future showAppUpdateDialog( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - dialogContext.l10n.releaseNotes, - style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 10), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 210), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: _ReleaseNotes( - notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, - ), + AppUpdateNotesViewport( + child: _ReleaseNotes( + notes: releaseNotes.isEmpty ? dialogContext.l10n.noReleaseNotes : releaseNotes, ), ), if (updating) ...[ @@ -157,7 +156,6 @@ Future showAppUpdateDialog( onPressed: () => unawaited(update()), loading: updating, variant: AppLoadingButtonVariant.primary, - icon: const Icon(Icons.system_update_alt_outlined, size: 17), child: Text(dialogContext.l10n.newVersionUpdate), ), ], diff --git a/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart b/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart new file mode 100644 index 00000000..51dbe968 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/app_update_notes_viewport.dart @@ -0,0 +1,59 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../shared/theme/app_design_tokens.dart'; + +class AppUpdateNotesViewport extends StatefulWidget { + const AppUpdateNotesViewport({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _AppUpdateNotesViewportState(); +} + +class _AppUpdateNotesViewportState extends State { + final _controller = ScrollController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const fadeHeight = AppDesignTokens.space24; + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: shad.Scrollbar( + controller: _controller, + thumbVisibility: true, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + // Mask colors control opacity only; they do not tint the content. + colors: const [shad.Colors.white, shad.Colors.white, shad.Colors.transparent], + stops: [0, (1 - fadeHeight / bounds.height).clamp(0.0, 1.0), 1], + ).createShader(bounds), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + controller: _controller, + padding: const EdgeInsetsDirectional.fromSTEB( + AppDesignTokens.space4, + 0, + AppDesignTokens.space16, + // Keep the final line above the fade at the end of the scroll. + fadeHeight, + ), + child: widget.child, + ), + ), + ), + ), + ); + } +} diff --git a/ui/flutter/test/updater_test.dart b/ui/flutter/test/updater_test.dart index 6ec81862..6ea25fad 100644 --- a/ui/flutter/test/updater_test.dart +++ b/ui/flutter/test/updater_test.dart @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart'; +import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/util/arch/arch.dart'; import 'package:gopeed/util/github_mirror.dart'; @@ -193,10 +195,47 @@ void main() { expect(find.text('下载更快'), findsNothing); expect(find.text('Later'), findsOneWidget); expect(find.text('Update Now'), findsOneWidget); + expect(find.text("What's New"), findsNothing); + final versionText = tester.widget(find.text('2.0.0-beta.1 → 2.0.0-beta.2')); + expect(versionText.style?.decoration, TextDecoration.none); + expect(versionText.style?.inherit, isFalse); + expect(find.byIcon(Icons.system_update_alt_outlined), findsNothing); final dialog = tester.getRect(find.byKey(const ValueKey('app-update-dialog'))); expect(dialog.width, lessThan(320)); expect(tester.getCenter(find.byKey(const ValueKey('app-update-heading'))).dx, closeTo(dialog.center.dx, 0.01)); expect(tester.getCenter(find.byKey(const ValueKey('app-update-actions'))).dx, closeTo(dialog.center.dx, 0.01)); expect(tester.takeException(), isNull); }); + + testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async { + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.dark(), + materialTheme: AppTheme.materialDark(), + home: Center( + child: SizedBox( + width: 280, + child: AppUpdateNotesViewport( + child: Column(children: [for (var i = 0; i < 30; i++) Text('Release note $i')]), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final scrollbar = tester.widget(find.byType(shad.Scrollbar)); + expect(scrollbar.thumbVisibility, isTrue); + final controller = scrollbar.controller!; + expect(controller.position.maxScrollExtent, greaterThan(0)); + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + final viewport = tester.getRect(find.byType(AppUpdateNotesViewport)); + final lastLine = tester.getRect(find.text('Release note 29')); + expect(lastLine.bottom, lessThanOrEqualTo(viewport.bottom - 24)); + expect(lastLine.top, greaterThan(viewport.top)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); }