diff --git a/lib/features/settings/presentation/settings_drawer.dart b/lib/features/settings/presentation/settings_drawer.dart index bd3ed18..241b350 100644 --- a/lib/features/settings/presentation/settings_drawer.dart +++ b/lib/features/settings/presentation/settings_drawer.dart @@ -8,6 +8,7 @@ import '../../updates/presentation/update_view_model.dart'; import '../domain/app_settings.dart'; import '../domain/login_item_status.dart'; import 'settings_view_model.dart'; +import 'widgets/compact_settings_item.dart'; import 'widgets/compact_settings_toggle.dart'; import 'widgets/update_settings_section.dart'; @@ -164,17 +165,20 @@ class _AlwaysOnTopSetting extends StatelessWidget { @override Widget build(BuildContext context) { final enabled = !viewModel.isSaving; - return _SettingsToggleRow( - settingKey: const Key('always-on-top-setting'), - toggleKey: const Key('always-on-top-toggle'), + return CompactSettingsItem( + key: const Key('always-on-top-setting'), label: context.l10n.alwaysOnTopLabel, - value: viewModel.alwaysOnTop, - enabled: enabled, - onTap: enabled + toggled: viewModel.alwaysOnTop, + onPressed: enabled ? () { unawaited(viewModel.setAlwaysOnTop(!viewModel.alwaysOnTop)); } : null, + trailing: CompactSettingsToggle( + key: const Key('always-on-top-toggle'), + value: viewModel.alwaysOnTop, + enabled: enabled, + ), ); } } @@ -187,17 +191,20 @@ class _OpenAtLoginSetting extends StatelessWidget { @override Widget build(BuildContext context) { final enabled = viewModel.canChangeOpenAtLogin; - return _SettingsToggleRow( - settingKey: const Key('open-at-login-setting'), - toggleKey: const Key('open-at-login-toggle'), + return CompactSettingsItem( + key: const Key('open-at-login-setting'), label: context.l10n.openAtLoginLabel, - value: viewModel.openAtLogin, - enabled: enabled, - onTap: enabled + toggled: viewModel.openAtLogin, + onPressed: enabled ? () { unawaited(viewModel.setOpenAtLogin(!viewModel.openAtLogin)); } : null, + trailing: CompactSettingsToggle( + key: const Key('open-at-login-toggle'), + value: viewModel.openAtLogin, + enabled: enabled, + ), ); } } @@ -210,13 +217,11 @@ class _CollapseWhenClickingOutsideSetting extends StatelessWidget { @override Widget build(BuildContext context) { final enabled = !viewModel.isSaving; - return _SettingsToggleRow( - settingKey: const Key('collapse-when-clicking-outside-setting'), - toggleKey: const Key('collapse-when-clicking-outside-toggle'), + return CompactSettingsItem( + key: const Key('collapse-when-clicking-outside-setting'), label: context.l10n.collapseWhenClickingOutsideLabel, - value: viewModel.collapseWhenClickingOutside, - enabled: enabled, - onTap: enabled + toggled: viewModel.collapseWhenClickingOutside, + onPressed: enabled ? () { unawaited( viewModel.setCollapseWhenClickingOutside( @@ -225,76 +230,10 @@ class _CollapseWhenClickingOutsideSetting extends StatelessWidget { ); } : null, - ); - } -} - -class _SettingsToggleRow extends StatelessWidget { - const _SettingsToggleRow({ - required this.settingKey, - required this.toggleKey, - required this.label, - required this.value, - required this.enabled, - required this.onTap, - }); - - final Key settingKey; - final Key toggleKey; - final String label; - final bool value; - final bool enabled; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Semantics( - label: label, - toggled: value, - enabled: enabled, - child: ExcludeSemantics( - child: Material( - color: Colors.transparent, - child: InkWell( - key: settingKey, - borderRadius: BorderRadius.circular(8), - hoverColor: theme.colorScheme.primary.withValues(alpha: 0.06), - highlightColor: theme.colorScheme.primary.withValues(alpha: 0.10), - onTap: onTap, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 34), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - children: [ - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: enabled - ? null - : theme.colorScheme.onSurface.withValues( - alpha: 0.38, - ), - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 12), - CompactSettingsToggle( - key: toggleKey, - value: value, - enabled: enabled, - ), - ], - ), - ), - ), - ), - ), + trailing: CompactSettingsToggle( + key: const Key('collapse-when-clicking-outside-toggle'), + value: viewModel.collapseWhenClickingOutside, + enabled: enabled, ), ); } diff --git a/lib/features/settings/presentation/widgets/compact_settings_item.dart b/lib/features/settings/presentation/widgets/compact_settings_item.dart new file mode 100644 index 0000000..4396d5d --- /dev/null +++ b/lib/features/settings/presentation/widgets/compact_settings_item.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +const _compactSettingsItemMinHeight = 34.0; +const _compactSettingsItemRadius = 8.0; +const _compactSettingsItemLabelFontSize = 11.0; + +class CompactSettingsItem extends StatelessWidget { + const CompactSettingsItem({ + required this.label, + required this.trailing, + required this.onPressed, + this.toggled, + super.key, + }); + + final String label; + final Widget trailing; + final VoidCallback? onPressed; + final bool? toggled; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final enabled = onPressed != null; + + return Semantics( + button: toggled == null, + enabled: enabled, + label: label, + toggled: toggled, + child: ExcludeSemantics( + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(_compactSettingsItemRadius), + hoverColor: theme.colorScheme.primary.withValues(alpha: 0.06), + highlightColor: theme.colorScheme.primary.withValues(alpha: 0.10), + onTap: onPressed, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _compactSettingsItemMinHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + Expanded( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withValues( + alpha: 0.38, + ), + fontSize: _compactSettingsItemLabelFontSize, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + ), + const SizedBox(width: 12), + trailing, + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/settings/presentation/widgets/update_settings_section.dart b/lib/features/settings/presentation/widgets/update_settings_section.dart index d514a01..730a2c6 100644 --- a/lib/features/settings/presentation/widgets/update_settings_section.dart +++ b/lib/features/settings/presentation/widgets/update_settings_section.dart @@ -4,11 +4,9 @@ import 'package:flutter/material.dart'; import '../../../../l10n/l10n.dart'; import '../../../updates/presentation/update_view_model.dart'; +import 'compact_settings_item.dart'; import 'compact_settings_toggle.dart'; -const _updateRowHeight = 34.0; -const _updateRowRadius = 8.0; - class UpdateSettingsSection extends StatelessWidget { const UpdateSettingsSection({required this.viewModel, super.key}); @@ -59,7 +57,7 @@ class UpdateSettingsSection extends StatelessWidget { ], ), const SizedBox(height: 6), - _UpdateSettingRow( + CompactSettingsItem( key: const Key('automatic-update-checks'), label: localizations.automaticUpdateChecksLabel, toggled: viewModel.automaticallyChecksForUpdates, @@ -79,7 +77,7 @@ class UpdateSettingsSection extends StatelessWidget { ), ), const SizedBox(height: 2), - _UpdateSettingRow( + CompactSettingsItem( key: const Key('check-for-updates'), onPressed: viewModel.isLoading || viewModel.isChecking ? null @@ -118,71 +116,6 @@ class UpdateSettingsSection extends StatelessWidget { } } -class _UpdateSettingRow extends StatelessWidget { - const _UpdateSettingRow({ - required this.label, - required this.trailing, - required this.onPressed, - this.toggled, - super.key, - }); - - final String label; - final Widget trailing; - final VoidCallback? onPressed; - final bool? toggled; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Semantics( - button: toggled == null, - enabled: onPressed != null, - label: label, - toggled: toggled, - child: ExcludeSemantics( - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(_updateRowRadius), - hoverColor: theme.colorScheme.primary.withValues(alpha: 0.06), - highlightColor: theme.colorScheme.primary.withValues(alpha: 0.10), - onTap: onPressed, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: _updateRowHeight), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - children: [ - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: onPressed == null - ? theme.colorScheme.onSurface.withValues( - alpha: 0.38, - ) - : null, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 12), - trailing, - ], - ), - ), - ), - ), - ), - ), - ); - } -} - class _UpdateStatus extends StatelessWidget { const _UpdateStatus({ required this.message, diff --git a/pubspec.yaml b/pubspec.yaml index e1b9ef0..cd9f5d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: floatick description: A focused, local-first floating todo list for macOS. publish_to: 'none' -version: 0.3.0+7 +version: 0.3.1+8 environment: sdk: ^3.12.2 diff --git a/test/app/floatick_app_test.dart b/test/app/floatick_app_test.dart index ad0109f..284a425 100644 --- a/test/app/floatick_app_test.dart +++ b/test/app/floatick_app_test.dart @@ -206,6 +206,16 @@ void main() { expect(find.text('每天检查一次,安装前会询问你'), findsNothing); expect(find.text('自动检查'), findsOneWidget); expect(find.text('立即检查'), findsOneWidget); + final collapseWhenClickingOutsideText = tester.widget( + find.text('点击外部时收起'), + ); + expect(collapseWhenClickingOutsideText.maxLines, 2); + expect(collapseWhenClickingOutsideText.overflow, TextOverflow.ellipsis); + expect(collapseWhenClickingOutsideText.style?.fontSize, 11); + final automaticUpdateChecksText = tester.widget(find.text('自动检查')); + expect(automaticUpdateChecksText.maxLines, 2); + expect(automaticUpdateChecksText.overflow, TextOverflow.ellipsis); + expect(automaticUpdateChecksText.style?.fontSize, 11); expect(find.byType(Switch), findsNothing); expect( tester.getSize(find.byKey(const Key('automatic-update-toggle'))), diff --git a/website/src/content/changelog.ts b/website/src/content/changelog.ts index 116a734..8c0131c 100644 --- a/website/src/content/changelog.ts +++ b/website/src/content/changelog.ts @@ -81,6 +81,21 @@ export const changelogCopy: Record = { export const changelogEntries: Record = { en: [ + { + version: 'v0.3.1', + date: 'July 31, 2026', + dateTime: '2026-07-31', + title: 'Clearer Settings and mobile preview', + summary: + 'This patch keeps long Settings labels readable and improves the mobile product showcase.', + highlights: [ + 'Show compact Settings labels on up to two lines instead of truncating them.', + 'Render a sharper, closer 3D product view on mobile.', + 'Remove the oversized 3D backdrop on narrow screens so the app panels stay in focus.', + ], + releaseUrl: 'https://github.com/lucaslushuo/floatick/releases/tag/v0.3.1', + compareUrl: 'https://github.com/lucaslushuo/floatick/compare/v0.3.0...v0.3.1', + }, { version: 'v0.3.0', date: 'July 29, 2026', @@ -130,6 +145,21 @@ export const changelogEntries: Record = { }, ], zh: [ + { + version: 'v0.3.1', + date: '2026 年 7 月 31 日', + dateTime: '2026-07-31', + title: '更清晰的设置项与移动端预览', + summary: + '这个补丁让较长的设置项文案保持可读,并优化移动端产品展示。', + highlights: [ + '紧凑设置项最多显示两行,不再过早截断。', + '移动端使用更清晰、更聚焦的 3D 产品视图。', + '窄屏隐藏过大的 3D 背景板,让应用面板成为视觉焦点。', + ], + releaseUrl: 'https://github.com/lucaslushuo/floatick/releases/tag/v0.3.1', + compareUrl: 'https://github.com/lucaslushuo/floatick/compare/v0.3.0...v0.3.1', + }, { version: 'v0.3.0', date: '2026 年 7 月 29 日', diff --git a/website/src/content/site-copy.ts b/website/src/content/site-copy.ts index 01138ce..5dc69ce 100644 --- a/website/src/content/site-copy.ts +++ b/website/src/content/site-copy.ts @@ -250,12 +250,12 @@ export const siteCopy: Record = { body: 'Each release lists its new features, fixes, and behavior changes.', latestLabel: 'Latest release', - version: 'v0.3.0', - date: 'July 29, 2026', + version: 'v0.3.1', + date: 'July 31, 2026', highlights: [ - 'Copy a todo title and notes together as Markdown.', - 'Use consistent bottom-sheet actions across the app.', - 'Scroll smoothly through larger local lists.', + 'Keep long Settings labels readable over up to two lines.', + 'Show a sharper and more focused 3D product preview on mobile.', + 'Remove the oversized 3D backdrop from narrow screens.', ], viewAll: 'Read the full changelog', }, @@ -455,12 +455,12 @@ export const siteCopy: Record = { body: '每个版本都会列出新增功能、问题修复和行为变化。', latestLabel: '最新版本', - version: 'v0.3.0', - date: '2026 年 7 月 29 日', + version: 'v0.3.1', + date: '2026 年 7 月 31 日', highlights: [ - '把 Todo 标题和内容一起复制为 Markdown。', - '统一应用内的底部抽屉操作方式。', - '优化大量 Todo 下的列表滚动。', + '较长的设置项文案最多显示两行。', + '移动端 3D 产品预览更清晰、更聚焦。', + '在窄屏移除过大的 3D 背景板。', ], viewAll: '查看完整更新日志', }, diff --git a/website/src/layouts/SiteLayout.astro b/website/src/layouts/SiteLayout.astro index 13d002f..3631e50 100644 --- a/website/src/layouts/SiteLayout.astro +++ b/website/src/layouts/SiteLayout.astro @@ -86,7 +86,7 @@ const softwareSchema = { applicationCategory: 'ProductivityApplication', applicationSubCategory: 'Todo List Application', operatingSystem: 'macOS 10.15 or later', - softwareVersion: '0.3.0', + softwareVersion: '0.3.1', isAccessibleForFree: true, url: canonicalUrl.href, downloadUrl: latestDownloadUrl, diff --git a/website/src/scripts/product-hero-scene.ts b/website/src/scripts/product-hero-scene.ts index f7a2f9d..a772bf0 100644 --- a/website/src/scripts/product-hero-scene.ts +++ b/website/src/scripts/product-hero-scene.ts @@ -94,8 +94,32 @@ const TEXTURE_ANISOTROPY = 16; const PANEL_FACE_INSET = 0.018; const PANEL_FACE_DEPTH_OFFSET = 0.004; const PANEL_FACE_CURVE_SEGMENTS = 16; -const DESKTOP_RENDER_PIXEL_RATIO = 2; -const COMPACT_RENDER_PIXEL_RATIO = 1.35; +const MAX_RENDER_PIXEL_RATIO = 2; +const COMPACT_VIEWPORT_MAX_WIDTH = 720; +const NARROW_VIEWPORT_MAX_WIDTH = 560; +const MEDIUM_VIEWPORT_MAX_WIDTH = 760; +// Small screens use a close-up composition instead of shrinking the full +// landscape frame until its product UI becomes unreadable. +const SCENE_VIEWPORT_LAYOUTS = { + narrow: { + cameraFieldOfView: 34, + cameraDistance: 19.8, + rootScale: 0.94, + showBackdrop: false, + }, + compact: { + cameraFieldOfView: 31, + cameraDistance: 20.3, + rootScale: 0.86, + showBackdrop: false, + }, + desktop: { + cameraFieldOfView: 31, + cameraDistance: 20.8, + rootScale: 0.82, + showBackdrop: true, + }, +} as const; const CONVEYOR_RAIL_RADIUS = 0.105; const CONVEYOR_CARRIER_DEPTH = 0.16; const CONVEYOR_CARRIER_GAP = 0.045; @@ -145,6 +169,16 @@ const PANEL_LAYOUT = { const COIN_BASE_POSITION = new Vector3(3.52, -3.22, 2.16); +function sceneViewportLayout(width: number) { + if (width < NARROW_VIEWPORT_MAX_WIDTH) { + return SCENE_VIEWPORT_LAYOUTS.narrow; + } + if (width < MEDIUM_VIEWPORT_MAX_WIDTH) { + return SCENE_VIEWPORT_LAYOUTS.compact; + } + return SCENE_VIEWPORT_LAYOUTS.desktop; +} + function dataValue(stage: HTMLElement, key: keyof DOMStringMap, fallback: string) { const value = stage.dataset[key]; return value?.trim() || fallback; @@ -1198,12 +1232,15 @@ function buildProductScene( const reducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)', ).matches; - const compactViewport = window.matchMedia('(max-width: 720px)').matches; + const compactViewport = window.matchMedia( + `(max-width: ${COMPACT_VIEWPORT_MAX_WIDTH}px)`, + ).matches; + const initialViewportLayout = sceneViewportLayout(stage.clientWidth); const disposables: Disposable[] = []; const renderer = new WebGLRenderer({ canvas, - antialias: !compactViewport, + antialias: true, alpha: true, powerPreference: 'high-performance', }); @@ -1213,24 +1250,24 @@ function buildProductScene( renderer.shadowMap.enabled = !compactViewport; renderer.shadowMap.type = PCFSoftShadowMap; renderer.setPixelRatio( - Math.min( - window.devicePixelRatio, - compactViewport - ? COMPACT_RENDER_PIXEL_RATIO - : DESKTOP_RENDER_PIXEL_RATIO, - ), + Math.min(window.devicePixelRatio, MAX_RENDER_PIXEL_RATIO), ); const scene = new Scene(); scene.fog = new FogExp2('#071113', 0.018); - const camera = new PerspectiveCamera(31, 1, 0.1, 50); - camera.position.set(0, 0.25, compactViewport ? 21.8 : 20.8); + const camera = new PerspectiveCamera( + initialViewportLayout.cameraFieldOfView, + 1, + 0.1, + 50, + ); + camera.position.set(0, 0.25, initialViewportLayout.cameraDistance); camera.lookAt(0, 0, 0); const root = new Group(); root.rotation.set(-0.035, -0.055, -0.018); - root.scale.setScalar(compactViewport ? 0.67 : 0.82); + root.scale.setScalar(initialViewportLayout.rootScale); scene.add(root); const ambient = new HemisphereLight('#c5fff9', '#17393d', 1.68); @@ -1272,6 +1309,7 @@ function buildProductScene( platform.position.z = -1.36; platform.receiveShadow = true; platform.castShadow = true; + platform.visible = initialViewportLayout.showBackdrop; root.add(platform); addBezelBolts( platform, @@ -1304,10 +1342,12 @@ function buildProductScene( ); innerPlatform.position.z = -1.08; innerPlatform.receiveShadow = true; + innerPlatform.visible = initialViewportLayout.showBackdrop; root.add(innerPlatform); disposables.push(innerPlatformGeometry, innerPlatformMaterial); const conveyorAssembly = new Group(); + conveyorAssembly.visible = initialViewportLayout.showBackdrop; root.add(conveyorAssembly); const { curve, carriers } = addConveyorTrack( conveyorAssembly, @@ -1449,6 +1489,7 @@ function buildProductScene( ); contactShadow.rotation.x = -Math.PI / 2; contactShadow.position.set(0, -4.18, -0.16); + contactShadow.visible = initialViewportLayout.showBackdrop; scene.add(contactShadow); disposables.push( contactShadowTexture, @@ -1482,10 +1523,15 @@ function buildProductScene( renderedWidth = width; renderedHeight = height; renderer.setSize(width, height, false); + const viewportLayout = sceneViewportLayout(width); camera.aspect = width / height; - camera.fov = width < 560 ? 40 : 31; - camera.position.z = width < 560 ? 21.8 : 20.8; - root.scale.setScalar(width < 560 ? 0.67 : width < 760 ? 0.76 : 0.82); + camera.fov = viewportLayout.cameraFieldOfView; + camera.position.z = viewportLayout.cameraDistance; + root.scale.setScalar(viewportLayout.rootScale); + platform.visible = viewportLayout.showBackdrop; + innerPlatform.visible = viewportLayout.showBackdrop; + conveyorAssembly.visible = viewportLayout.showBackdrop; + contactShadow.visible = viewportLayout.showBackdrop; camera.updateProjectionMatrix(); render(); }; @@ -1561,14 +1607,16 @@ function buildProductScene( coinAssembly.rotation.z = -0.12 + Math.sin(elapsed * 0.64) * 0.08; - carriers.forEach((carrier, index) => { - const progress = (elapsed * 0.025 + index / carriers.length) % 1; - const position = curve.getPointAt(progress); - const tangent = curve.getTangentAt(progress); - carrier.position.copy(position); - carrier.position.z += CONVEYOR_CARRIER_Z_OFFSET; - carrier.rotation.set(0, 0, Math.atan2(tangent.y, tangent.x)); - }); + if (conveyorAssembly.visible) { + carriers.forEach((carrier, index) => { + const progress = (elapsed * 0.025 + index / carriers.length) % 1; + const position = curve.getPointAt(progress); + const tangent = curve.getTangentAt(progress); + carrier.position.copy(position); + carrier.position.z += CONVEYOR_CARRIER_Z_OFFSET; + carrier.rotation.set(0, 0, Math.atan2(tangent.y, tangent.x)); + }); + } render(); animationFrame = window.requestAnimationFrame(animate); @@ -1591,6 +1639,11 @@ function buildProductScene( const handlePointerMove = (event: PointerEvent) => { if (event.pointerType === 'touch' || reducedMotion) return; + if (!platform.visible) { + isExpanded = false; + resetPointer(); + return; + } const bounds = stage.getBoundingClientRect(); pointerTarget.set( ((event.clientX - bounds.left) / bounds.width - 0.5) * 2, diff --git a/website/src/styles/global.css b/website/src/styles/global.css index f9b67b3..96c3948 100644 --- a/website/src/styles/global.css +++ b/website/src/styles/global.css @@ -1792,7 +1792,7 @@ img { @media (max-width: 480px) { .product-hero-stage { - min-height: 490px; + min-height: 540px; } }