diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e005c..287f5e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ * Migrated from `package:flutter/material.dart` and `package:flutter/cupertino.dart` to the decoupled [`material_ui`](https://pub.dev/packages/material_ui) and [`cupertino_ui`](https://pub.dev/packages/cupertino_ui) packages (#207) * Requires Flutter >=3.44.0 and Dart SDK >=3.12.0 * Your app must use `MaterialApp`/`CupertinoApp` from `material_ui`/`cupertino_ui`. In an app still on `package:flutter/material.dart`, `SettingsList` can't read your theme and falls back to default colors and light mode. Stay on `^3.0.1` until your app has migrated. +* New default look that matches iOS 26/27, Android 16/17 and current Chrome settings (see Design refresh). Sizes, paddings and default colors changed. Colors you set in `SettingsThemeData` still win. +* iOS-style switch tiles (iOS, macOS, Windows) now show the new `CupertinoSettingsSwitch` instead of `CupertinoSwitch`. Tests that find `CupertinoSwitch` need to look for `CupertinoSettingsSwitch`. + +### Design refresh +* iOS, macOS, Windows: 26pt cards with continuous corners and 20pt side margins, 52pt rows with 17pt text, 17pt semibold section headers, and the iOS secondary grey for headers and footers. iOS no longer uses all-caps headers, so pass headers in sentence case. +* iOS, macOS, Windows: a new switch that matches the iOS 26/27 one, drawn entirely in Flutter. It has a 63x28 track and a pill-shaped thumb, and while it is pressed or dragged the thumb becomes a Liquid Glass-style lens. It is public as `CupertinoSettingsSwitch`, so you can use it outside settings lists too. +* Android, Linux, Fuchsia: every tile sits on its own card, 2dp apart, with 20dp corners at the ends of a group and 4dp in between, on a `surfaceContainer` page. 16sp titles, tighter rows, and switches that show a check or a cross. +* Web: Chrome-style white cards with 8px corners and a light shadow, 14px titles and 13px descriptions, 20px leading icons, a chevron on navigation tiles, near-black section titles, and a 680px column on wide screens (16px side margins in narrow windows). ### Bug fixes * Colors, dark mode and platform now follow the app theme in apps built on `material_ui` (#206) diff --git a/README.md b/README.md index 943272f..609a410 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ SettingsTile.navigation( ### `SettingsTile.switchTile` — switch tile -Renders a `Switch` on Material platforms and a `CupertinoSwitch` on iOS/macOS. +Renders a `Switch` on Material platforms and a `CupertinoSettingsSwitch` (the iOS 26+ switch) on iOS/macOS. ```dart SettingsTile.switchTile( diff --git a/assets/v3/android_material3.png b/assets/v3/android_material3.png index df46a7b..9a318f7 100644 Binary files a/assets/v3/android_material3.png and b/assets/v3/android_material3.png differ diff --git a/assets/v3/android_settings.png b/assets/v3/android_settings.png index b200844..36f2935 100644 Binary files a/assets/v3/android_settings.png and b/assets/v3/android_settings.png differ diff --git a/assets/v3/ios_cupertino.png b/assets/v3/ios_cupertino.png index 5627585..891f12b 100644 Binary files a/assets/v3/ios_cupertino.png and b/assets/v3/ios_cupertino.png differ diff --git a/assets/v3/web_chrome.png b/assets/v3/web_chrome.png index 7ebbe3b..24f3e17 100644 Binary files a/assets/v3/web_chrome.png and b/assets/v3/web_chrome.png differ diff --git a/example/integration_test/integration_test.dart b/example/integration_test/integration_test.dart index 2ae8f8e..9709e57 100644 --- a/example/integration_test/integration_test.dart +++ b/example/integration_test/integration_test.dart @@ -103,7 +103,7 @@ void main() { ); final cupertinoSwitchFinder = find.descendant( of: find.widgetWithText(SettingsTile, 'Enable custom theme'), - matching: find.byType(CupertinoSwitch), + matching: find.byType(CupertinoSettingsSwitch), ); final bool hasMaterialSwitch = switchFinder.evaluate().isNotEmpty; @@ -124,15 +124,18 @@ void main() { expect(tester.widget(switchFinder).value, false); } else { expect( - tester.widget(cupertinoSwitchFinder).value, false); + tester.widget(cupertinoSwitchFinder).value, + false); await tester.tap(cupertinoSwitchFinder); await pumpSettled(tester); expect( - tester.widget(cupertinoSwitchFinder).value, true); + tester.widget(cupertinoSwitchFinder).value, + true); await tester.tap(cupertinoSwitchFinder); await pumpSettled(tester); expect( - tester.widget(cupertinoSwitchFinder).value, false); + tester.widget(cupertinoSwitchFinder).value, + false); } await goBack(tester); @@ -207,13 +210,14 @@ void main() { .widget(find.widgetWithText(SettingsTile, 'Platform')); expect((iosTile.value as Text).data, 'iOS'); - // iOS uses CupertinoSwitch + // iOS uses CupertinoSettingsSwitch final iosSwitchFinder = find.descendant( of: find.widgetWithText(SettingsTile, 'Enable custom theme'), - matching: find.byType(CupertinoSwitch), + matching: find.byType(CupertinoSettingsSwitch), ); expect(iosSwitchFinder, findsOneWidget); - expect(tester.widget(iosSwitchFinder).value, false); + expect( + tester.widget(iosSwitchFinder).value, false); await goBack(tester); }); @@ -256,18 +260,18 @@ void main() { await tester.tap(find.text('Material 3 Theme Demo')); await pumpSettled(tester); - // The tile shows a Switch on Android and a CupertinoSwitch on iOS. + // The tile shows a Switch on Android and a CupertinoSettingsSwitch on iOS. final darkModeSwitchFinder = find.descendant( of: find.widgetWithText(SettingsTile, 'Dark mode'), matching: find.byWidgetPredicate( - (widget) => widget is Switch || widget is CupertinoSwitch, + (widget) => widget is Switch || widget is CupertinoSettingsSwitch, ), ); bool darkModeValue() { final widget = tester.widget(darkModeSwitchFinder); return widget is Switch ? widget.value - : (widget as CupertinoSwitch).value; + : (widget as CupertinoSettingsSwitch).value; } expect(darkModeSwitchFinder, findsOneWidget); @@ -317,15 +321,19 @@ void main() { // Toggle Dark Appearance final darkAppearanceSwitchFinder = find.descendant( of: find.widgetWithText(SettingsTile, 'Dark Appearance'), - matching: find.byType(CupertinoSwitch), + matching: find.byType(CupertinoSettingsSwitch), ); expect(darkAppearanceSwitchFinder, findsOneWidget); - final initial = - tester.widget(darkAppearanceSwitchFinder).value; + final initial = tester + .widget(darkAppearanceSwitchFinder) + .value; await tester.tap(darkAppearanceSwitchFinder); await pumpSettled(tester); - expect(tester.widget(darkAppearanceSwitchFinder).value, + expect( + tester + .widget(darkAppearanceSwitchFinder) + .value, !initial); await goBack(tester); diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 2429e19..e867712 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -377,7 +377,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 98676QY3V2; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -559,7 +558,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 98676QY3V2; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -582,7 +580,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 98676QY3V2; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/example/lib/screens/gallery/ios_developer_screen.dart b/example/lib/screens/gallery/ios_developer_screen.dart index c83f450..dbea8a0 100644 --- a/example/lib/screens/gallery/ios_developer_screen.dart +++ b/example/lib/screens/gallery/ios_developer_screen.dart @@ -25,7 +25,7 @@ class _IosDeveloperScreenState extends State { platform: DevicePlatform.iOS, sections: [ SettingsSection( - title: Text('APPEARANCE'), + title: Text('Appearance'), tiles: [ SettingsTile.switchTile( onToggle: (value) { @@ -39,7 +39,7 @@ class _IosDeveloperScreenState extends State { ], ), SettingsSection( - title: Text('DISPLAY ZOOM'), + title: Text('Display zoom'), tiles: [ SettingsTile.navigation( onPressed: (_) { @@ -60,7 +60,7 @@ class _IosDeveloperScreenState extends State { ], ), SettingsSection( - title: Text('UI AUTOMATION'), + title: Text('UI automation'), tiles: [ SettingsTile.switchTile( onToggle: (_) {}, @@ -78,7 +78,7 @@ class _IosDeveloperScreenState extends State { ], ), SettingsSection( - title: Text('STATE RESTORATION TESTING'), + title: Text('State restoration testing'), tiles: [ SettingsTile.switchTile( onToggle: (_) {}, @@ -95,7 +95,7 @@ class _IosDeveloperScreenState extends State { ], ), SettingsSection( - title: Text('IAD DEVELOPER APP TESTING'), + title: Text('iAd developer app testing'), tiles: [ SettingsTile.navigation( title: Text('Downtime'), diff --git a/lib/settings_ui.dart b/lib/settings_ui.dart index e568763..f90218c 100644 --- a/lib/settings_ui.dart +++ b/lib/settings_ui.dart @@ -7,3 +7,4 @@ export 'package:settings_ui/src/tiles/abstract_settings_tile.dart'; export 'package:settings_ui/src/tiles/settings_tile.dart'; export 'package:settings_ui/src/utils/platform_utils.dart'; export 'package:settings_ui/src/utils/settings_theme.dart'; +export 'package:settings_ui/src/tiles/platforms/cupertino_settings_switch.dart'; diff --git a/lib/src/list/settings_list.dart b/lib/src/list/settings_list.dart index 743aaa8..e269f7d 100644 --- a/lib/src/list/settings_list.dart +++ b/lib/src/list/settings_list.dart @@ -126,8 +126,12 @@ class SettingsList extends StatelessWidget { DevicePlatform platform, BuildContext context, ) { - if (MediaQuery.of(context).size.width > 810) { - double padding = (MediaQuery.of(context).size.width - 810) / 2; + // Chrome's settings page uses a narrower 680px column than the other + // platforms' 810px. + final maxContentWidth = platform == DevicePlatform.web ? 680.0 : 810.0; + final screenWidth = MediaQuery.of(context).size.width; + if (screenWidth > maxContentWidth) { + double padding = (screenWidth - maxContentWidth) / 2; switch (platform) { case DevicePlatform.android: case DevicePlatform.fuchsia: @@ -137,7 +141,10 @@ class SettingsList extends StatelessWidget { case DevicePlatform.windows: return EdgeInsets.symmetric(horizontal: padding); case DevicePlatform.web: - return EdgeInsets.symmetric(vertical: 20, horizontal: padding); + return EdgeInsets.symmetric( + vertical: 20, + horizontal: padding < 16 ? 16 : padding, + ); case DevicePlatform.device: throw Exception( 'You can\'t use the DevicePlatform.device in this context. ' @@ -154,7 +161,8 @@ class SettingsList extends StatelessWidget { case DevicePlatform.windows: return const EdgeInsets.symmetric(vertical: 0); case DevicePlatform.web: - return const EdgeInsets.symmetric(vertical: 20); + // Keep the cards off the screen edges in narrow browser windows. + return const EdgeInsets.symmetric(vertical: 20, horizontal: 16); case DevicePlatform.device: throw Exception( 'You can\'t use the DevicePlatform.device in this context. ' diff --git a/lib/src/sections/platforms/android_settings_section.dart b/lib/src/sections/platforms/android_settings_section.dart index 77e948a..9903428 100644 --- a/lib/src/sections/platforms/android_settings_section.dart +++ b/lib/src/sections/platforms/android_settings_section.dart @@ -24,50 +24,69 @@ class AndroidSettingsSection extends StatelessWidget { Widget buildSectionBody(BuildContext context) { final theme = SettingsTheme.of(context); final textScaler = MediaQuery.textScalerOf(context); - final tileList = buildTileList(); - - if (title == null) { - return tileList; - } return Padding( padding: margin ?? EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: - titlePadding ?? - EdgeInsetsDirectional.only( - top: textScaler.scale(24), - bottom: textScaler.scale(10), - start: 24, - end: 24, - ), - child: DefaultTextStyle( - style: (theme.themeData.titleTextStyle ?? const TextStyle()) - .copyWith(color: theme.themeData.titleTextColor), - child: title!, - ), - ), - Container( - color: theme.themeData.settingsSectionBackground, - child: tileList, - ), + if (title != null) + Padding( + padding: + titlePadding ?? + EdgeInsetsDirectional.only( + top: textScaler.scale(24), + bottom: textScaler.scale(8), + start: 24, + end: 24, + ), + child: DefaultTextStyle( + style: + (theme.themeData.titleTextStyle ?? + const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + )) + .copyWith(color: theme.themeData.titleTextColor), + child: title!, + ), + ) + else + SizedBox(height: textScaler.scale(16)), + buildTileList(theme), ], ), ); } - Widget buildTileList() { - return ListView.builder( - shrinkWrap: true, - itemCount: tiles.length, - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (BuildContext context, int index) { - return tiles[index]; - }, + /// Android 16+ settings put each tile on its own card: 2dp apart, with + /// large corners at the ends of the group and small ones in between. + Widget buildTileList(SettingsTheme theme) { + const outer = Radius.circular(20); + const inner = Radius.circular(4); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + for (var i = 0; i < tiles.length; i++) + Padding( + padding: EdgeInsets.only(top: i == 0 ? 0 : 2), + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: i == 0 ? outer : inner, + bottom: i == tiles.length - 1 ? outer : inner, + ), + child: ColoredBox( + color: + theme.themeData.settingsSectionBackground ?? + const Color(0x00000000), + child: tiles[i], + ), + ), + ), + ], + ), ); } } diff --git a/lib/src/sections/platforms/ios_settings_section.dart b/lib/src/sections/platforms/ios_settings_section.dart index 66fc07f..d2d85a7 100644 --- a/lib/src/sections/platforms/ios_settings_section.dart +++ b/lib/src/sections/platforms/ios_settings_section.dart @@ -32,8 +32,8 @@ class IOSSettingsSection extends StatelessWidget { bottom: isLastNonDescriptive ? textScaler.scale(27) : textScaler.scale(10), - left: 16, - right: 16, + left: 20, + right: 20, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -43,13 +43,16 @@ class IOSSettingsSection extends StatelessWidget { padding: titlePadding ?? EdgeInsetsDirectional.only( - start: 18, - bottom: textScaler.scale(5), + start: 16, + bottom: textScaler.scale(8), ), child: DefaultTextStyle( style: (theme.themeData.titleTextStyle ?? - const TextStyle(fontSize: 13)) + const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + )) .copyWith(color: theme.themeData.titleTextColor), child: title!, ), diff --git a/lib/src/sections/platforms/web_settings_section.dart b/lib/src/sections/platforms/web_settings_section.dart index a6aaea3..ea5a64d 100644 --- a/lib/src/sections/platforms/web_settings_section.dart +++ b/lib/src/sections/platforms/web_settings_section.dart @@ -30,28 +30,34 @@ class WebSettingsSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (title != null) - Container( - height: textScaler.scale(65), + Padding( padding: titlePadding ?? EdgeInsetsDirectional.only( - bottom: textScaler.scale(5), - start: 6, - top: textScaler.scale(40), + top: textScaler.scale(24), + bottom: textScaler.scale(12), ), child: DefaultTextStyle( style: (theme.themeData.titleTextStyle ?? - const TextStyle(fontSize: 15)) + const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + )) .copyWith(color: theme.themeData.titleTextColor), child: title!, ), - ), + ) + else + // Card no longer has a margin, so keep untitled sections apart. + SizedBox(height: textScaler.scale(8)), Card( + // No margin, so the card's left edge lines up with the title. + margin: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), - elevation: 4, + elevation: 2, color: theme.themeData.settingsSectionBackground, child: buildTileList(), ), @@ -70,7 +76,11 @@ class WebSettingsSection extends StatelessWidget { return tiles[index]; }, separatorBuilder: (BuildContext context, int index) { - return const Divider(height: 0, thickness: 1); + return Divider( + height: 0, + thickness: 1, + color: SettingsTheme.of(context).themeData.dividerColor, + ); }, ); } diff --git a/lib/src/tiles/platforms/android_settings_tile.dart b/lib/src/tiles/platforms/android_settings_tile.dart index 01eb3c2..8ce8430 100644 --- a/lib/src/tiles/platforms/android_settings_tile.dart +++ b/lib/src/tiles/platforms/android_settings_tile.dart @@ -1,6 +1,12 @@ import 'package:material_ui/material_ui.dart'; import 'package:settings_ui/settings_ui.dart'; +/// Pixel settings switches show a check when on and a cross when off. +final _thumbIcon = WidgetStateProperty.resolveWith( + (states) => + Icon(states.contains(WidgetState.selected) ? Icons.check : Icons.close), +); + class AndroidSettingsTile extends StatelessWidget { const AndroidSettingsTile({ required this.tileType, @@ -69,7 +75,7 @@ class AndroidSettingsTile extends StatelessWidget { Padding( padding: leadingPadding ?? - const EdgeInsetsDirectional.only(start: 24), + const EdgeInsetsDirectional.only(start: 16), child: IconTheme( data: IconTheme.of(context).copyWith( color: enabled @@ -82,10 +88,10 @@ class AndroidSettingsTile extends StatelessWidget { Expanded( child: Padding( padding: EdgeInsetsDirectional.only( - start: 24, - end: 24, - bottom: textScaler.scale(compact ? 9 : 19), - top: textScaler.scale(compact ? 9 : 19), + start: 16, + end: 16, + bottom: textScaler.scale(compact ? 6 : 12), + top: textScaler.scale(compact ? 6 : 12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -94,7 +100,7 @@ class AndroidSettingsTile extends StatelessWidget { style: (theme.themeData.tileTextStyle ?? const TextStyle( - fontSize: 18, + fontSize: 16, fontWeight: FontWeight.w400, )) .copyWith( @@ -153,10 +159,11 @@ class AndroidSettingsTile extends StatelessWidget { children: [ trailing!, Padding( - padding: const EdgeInsetsDirectional.only(end: 8), + padding: const EdgeInsetsDirectional.only(end: 12), child: Switch( value: initialValue, onChanged: onToggle, + thumbIcon: _thumbIcon, activeThumbColor: enabled ? activeSwitchColor : (theme.themeData.inactiveSwitchColor ?? @@ -167,10 +174,11 @@ class AndroidSettingsTile extends StatelessWidget { ) else if (tileType == SettingsTileType.switchTile) Padding( - padding: const EdgeInsetsDirectional.only(start: 16, end: 8), + padding: const EdgeInsetsDirectional.only(start: 16, end: 12), child: Switch( value: initialValue, onChanged: onToggle, + thumbIcon: _thumbIcon, activeThumbColor: enabled ? activeSwitchColor : theme.themeData.inactiveTitleColor, diff --git a/lib/src/tiles/platforms/cupertino_settings_switch.dart b/lib/src/tiles/platforms/cupertino_settings_switch.dart new file mode 100644 index 0000000..22486ec --- /dev/null +++ b/lib/src/tiles/platforms/cupertino_settings_switch.dart @@ -0,0 +1,981 @@ +import 'dart:math' as math; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/services.dart'; +import 'package:settings_ui/src/utils/settings_theme.dart'; + +// Measured on iOS 27 (iPhone 17 Pro simulator). All sizes are in points. +const double _kTrackWidth = 63.0; +const double _kTrackHeight = 28.0; +const double _kThumbWidth = 37.0; +const double _kThumbHeight = 24.0; +const double _kThumbInset = 2.0; + +/// How far the thumb moves between OFF and ON. +const double _kTravel = _kTrackWidth - _kThumbWidth - 2 * _kThumbInset; + +/// Size of the glass lens while the switch is held. +const double _kLensWidth = 59.33; +const double _kLensHeight = 39.67; + +/// How far the lens can be dragged past either rest position. +const double _kMaxOverDrag = 3.33; + +/// A drag flips the value once the lens is this far past the opposite rest +/// position. The midpoint is not the trigger. +const double _kFlipDistance = 1.0; + +/// After a tap, the lens shrinks back into the thumb once the thumb is this +/// close to its destination (the last part of the travel). +const double _kReleaseDistance = 3.5; + +const Color _kOffTrackLight = Color(0xFFC5C5C7); +const Color _kOffTrackDark = Color(0xFF5A5A5E); +const Color _kCellLight = Color(0xFFFFFFFF); +const Color _kCellDark = Color(0xFF1C1C1E); +const Color _kWhite = Color(0xFFFFFFFF); +const Color _kBlack = Color(0xFF000000); + +/// Grows the thumb into the lens: ~0.17 s response, damping ratio 0.62, so +/// it overshoots by about 4% after ~100 ms and settles in ~200 ms. +const SpringDescription _kPressSpring = SpringDescription( + mass: 1, + stiffness: 1444, + damping: 47.1, +); + +/// Shrinks the lens back into the thumb (critically damped, ~210 ms). +const SpringDescription _kReleaseSpring = SpringDescription( + mass: 1, + stiffness: 784, + damping: 56, +); + +/// Moves the thumb between OFF and ON (critically damped). It covers most +/// of the 22 pt in ~300 ms. +const SpringDescription _kPositionSpring = SpringDescription( + mass: 1, + stiffness: 225, + damping: 30, +); + +const Tolerance _kPositionTolerance = Tolerance(distance: 0.01, velocity: 0.1); +const Tolerance _kPressTolerance = Tolerance(distance: 0.001, velocity: 0.01); + +const Duration _kTrackOnDuration = Duration(milliseconds: 350); +const Duration _kTrackOffDuration = Duration(milliseconds: 210); +const Duration _kTintDuration = Duration(milliseconds: 390); +const Duration _kCatchUpDuration = Duration(milliseconds: 120); + +/// An on/off switch that looks and moves like the iOS 26+ `UISwitch`, the +/// "Liquid Glass" switch. +/// +/// At rest it is a 63x28 capsule track with a 37x24 white pill thumb. While +/// it is pressed or dragged, the thumb turns into a larger clear glass lens +/// that bends the track underneath it and overflows the track. Everything is +/// painted in Flutter with a [CustomPainter]: there is no platform view, no +/// shader and no backdrop filter. +/// +/// The widget always takes 63x28 of layout space. The lens paints outside +/// that box (up to about 12.5 points past the track ends and 6 points above +/// and below), so leave some room around the switch and do not clip it +/// tightly. +/// +/// Like `UISwitch`: +/// +/// * Tapping toggles the value. +/// * Dragging moves the lens with the finger. The value flips while the +/// finger is still down, once the lens passes the opposite end, not at +/// the midpoint. Letting go snaps the thumb to the current value. +/// * A drag that starts vertically is left to the enclosing scroll view. +/// * iOS and macOS play a light haptic on every value change. +/// +/// The switch does not keep its own state. [onChanged] is called with the +/// new value, and the parent rebuilds it with that value. If [onChanged] is +/// null, the switch is disabled: it is drawn at half opacity and ignores +/// input. +/// +/// The OFF track is `#C5C5C7` in light mode and `#5A5A5E` in dark mode, and +/// the ON track is the system green, unless [inactiveTrackColor] or +/// [activeTrackColor] is set. Light or dark comes from the [CupertinoTheme], +/// or from the platform brightness when the theme does not set one. +class CupertinoSettingsSwitch extends StatefulWidget { + /// Creates an iOS 26+ style switch. + const CupertinoSettingsSwitch({ + super.key, + required this.value, + required this.onChanged, + this.activeTrackColor, + this.inactiveTrackColor, + }); + + /// Whether the switch is on. + final bool value; + + /// Called with the new value when the user toggles the switch. + /// + /// If null, the switch is disabled. + final ValueChanged? onChanged; + + /// The track color when the switch is on. + /// + /// Defaults to [CupertinoColors.systemGreen]. + final Color? activeTrackColor; + + /// The track color when the switch is off. + /// + /// Defaults to `#C5C5C7` in light mode and `#5A5A5E` in dark mode. + final Color? inactiveTrackColor; + + @override + State createState() => + _CupertinoSettingsSwitchState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + FlagProperty('value', value: value, ifTrue: 'on', ifFalse: 'off'), + ); + properties.add( + ObjectFlagProperty>( + 'onChanged', + onChanged, + ifNull: 'disabled', + ), + ); + properties.add(ColorProperty('activeTrackColor', activeTrackColor)); + properties.add(ColorProperty('inactiveTrackColor', inactiveTrackColor)); + } +} + +class _CupertinoSettingsSwitchState extends State + with TickerProviderStateMixin { + /// Thumb offset from the OFF rest position, in points (0 = OFF, 22 = ON). + late final AnimationController _position = AnimationController.unbounded( + vsync: this, + value: _restFor(widget.value), + )..addListener(_maybeReleaseAfterTap); + + /// 0 = resting thumb, 1 = glass lens. Overshoots a little past 1. + late final AnimationController _press = AnimationController.unbounded( + vsync: this, + ); + + /// 0 = OFF track color, 1 = ON track color. + late final AnimationController _trackOn = AnimationController( + vsync: this, + value: widget.value ? 1 : 0, + ); + + /// How much of the track color still tints the thumb after a release. + late final AnimationController _tint = AnimationController(vsync: this); + + /// Eases the lens onto the finger when a drag starts, instead of jumping + /// by the touch slop. + late final AnimationController _catchUp = AnimationController( + vsync: this, + duration: _kCatchUpDuration, + value: 1, + )..addListener(_handleCatchUpTick); + + late final Map> _actions = >{ + ActivateIntent: CallbackAction( + onInvoke: (_) => _handleTap(), + ), + ButtonActivateIntent: CallbackAction( + onInvoke: (_) => _handleTap(), + ), + }; + + bool _reduceMotion = false; + bool _showFocusHighlight = false; + + /// The lens is shown (a finger is down, or a tap is still moving the thumb). + bool _pressed = false; + + /// A tap toggled the switch: shrink the lens when the thumb is almost there. + bool _releaseWhenArrived = false; + + bool _dragging = false; + bool _dragValue = false; + double _dragDownX = 0; + double _dragStartPosition = 0; + double _dragFingerDelta = 0; + double _dragCatchUpOffset = 0; + + bool get _enabled => widget.onChanged != null; + + /// +1 in left-to-right layouts, -1 in right-to-left ones. + double get _direction => + Directionality.maybeOf(context) == TextDirection.rtl ? -1 : 1; + + static double _restFor(bool value) => value ? _kTravel : 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + } + + @override + void didUpdateWidget(CupertinoSettingsSwitch oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _animateTrack(widget.value); + // While dragging, the finger owns the thumb. It snaps on release. + if (!_dragging) _animatePosition(_restFor(widget.value)); + } + if (!_enabled && (_dragging || _pressed)) { + _dragging = false; + _releaseWhenArrived = false; + _releaseLens(); + _animatePosition(_restFor(widget.value)); + } + } + + @override + void dispose() { + _position.dispose(); + _press.dispose(); + _trackOn.dispose(); + _tint.dispose(); + _catchUp.dispose(); + super.dispose(); + } + + // Animations. + + void _pressLens() { + if (_reduceMotion) return; + _pressed = true; + _press.animateWith( + SpringSimulation( + _kPressSpring, + _press.value, + 1, + _press.velocity, + tolerance: _kPressTolerance, + snapToEnd: true, + ), + ); + } + + void _releaseLens() { + if (!_pressed) return; + _pressed = false; + if (_reduceMotion) { + _press.value = 0; + return; + } + // The thumb comes back tinted by the track, then fades to white. A press + // too short to grow the lens leaves little tint. + _tint.value = (_press.value / 0.5).clamp(0.0, 1.0); + _tint.animateTo(0, duration: _kTintDuration, curve: Curves.easeInOut); + _press.animateWith( + SpringSimulation( + _kReleaseSpring, + _press.value, + 0, + _press.velocity, + tolerance: _kPressTolerance, + snapToEnd: true, + ), + ); + } + + void _animatePosition(double target, {double velocity = 0}) { + if (_reduceMotion) { + _position.value = target; + return; + } + _position.animateWith( + SpringSimulation( + _kPositionSpring, + _position.value, + target, + velocity, + tolerance: _kPositionTolerance, + snapToEnd: true, + ), + ); + } + + void _animateTrack(bool on) { + if (_reduceMotion) { + _trackOn.value = on ? 1 : 0; + return; + } + _trackOn.animateTo( + on ? 1 : 0, + duration: on ? _kTrackOnDuration : _kTrackOffDuration, + curve: Curves.easeOut, + ); + } + + void _maybeReleaseAfterTap() { + if (!_releaseWhenArrived || _dragging) return; + final double distance = (_position.value - _restFor(widget.value)).abs(); + if (distance <= _kReleaseDistance) { + _releaseWhenArrived = false; + _releaseLens(); + } + } + + void _commit(bool value) { + widget.onChanged?.call(value); + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + HapticFeedback.lightImpact(); + } + } + + // Taps, keyboard and accessibility actions. + + void _handleTapDown(TapDownDetails details) { + // A finger is down again: keep the lens until it lifts. + _releaseWhenArrived = false; + _pressLens(); + } + + void _handleTap() { + if (!_enabled) return; + _pressLens(); + _releaseWhenArrived = true; + _commit(!widget.value); + // If the parent rebuilds with the new value, the thumb starts moving and + // the lens shrinks near the end of the travel. If it does not, the thumb + // stays put and the lens shrinks right after the next frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _maybeReleaseAfterTap(); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void _handleTapCancel() { + // A scroll or a drag took over. A drag keeps the lens (it presses again + // right after this). + if (!_dragging) _releaseLens(); + } + + // Dragging. + + void _handleDragDown(DragDownDetails details) { + _dragDownX = details.localPosition.dx; + } + + void _handleDragStart(DragStartDetails details) { + _dragging = true; + _releaseWhenArrived = false; + _dragValue = widget.value; + _dragStartPosition = _position.value; + _dragFingerDelta = _direction * (details.localPosition.dx - _dragDownX); + // The finger has already moved by the touch slop. Follow it from the + // point where it touched down, but ease into that instead of jumping. + _dragCatchUpOffset = + _dragStartPosition - _rubberBand(_dragStartPosition + _dragFingerDelta); + if (_reduceMotion) { + _catchUp.value = 1; + } else { + _catchUp.forward(from: 0); + } + _pressLens(); + _updateDragPosition(); + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (!_dragging) return; + _dragFingerDelta = _direction * (details.localPosition.dx - _dragDownX); + _updateDragPosition(); + } + + void _handleCatchUpTick() { + if (_dragging) _updateDragPosition(); + } + + void _updateDragPosition() { + final double target = _rubberBand(_dragStartPosition + _dragFingerDelta); + final double lag = 1 - Curves.easeOutCubic.transform(_catchUp.value); + _position.value = target + _dragCatchUpOffset * lag; + + if (!_dragValue && target > _kTravel + _kFlipDistance) { + _dragValue = true; + _commit(true); + } else if (_dragValue && target < -_kFlipDistance) { + _dragValue = false; + _commit(false); + } + } + + void _handleDragEnd(DragEndDetails details) { + _finishDrag(_direction * (details.primaryVelocity ?? 0)); + } + + void _handleDragCancel() => _finishDrag(0); + + void _finishDrag(double velocity) { + if (!_dragging) return; + _dragging = false; + _catchUp.value = 1; + _releaseLens(); + _animatePosition( + _restFor(widget.value), + velocity: velocity.clamp(-600.0, 600.0), + ); + } + + /// Follows the finger 1:1 between the rest positions, then resists and + /// stops [_kMaxOverDrag] points past them. + static double _rubberBand(double position) { + if (position > _kTravel) { + return _kTravel + + _kMaxOverDrag * _tanh((position - _kTravel) / _kMaxOverDrag); + } + if (position < 0) { + return -_kMaxOverDrag * _tanh(-position / _kMaxOverDrag); + } + return position; + } + + static double _tanh(double x) { + final double e = math.exp(2 * math.min(x, 20.0)); + return (e - 1) / (e + 1); + } + + @override + Widget build(BuildContext context) { + final Brightness brightness = + CupertinoTheme.of(context).brightness ?? + MediaQuery.maybePlatformBrightnessOf(context) ?? + Brightness.light; + final bool isDark = brightness == Brightness.dark; + final bool highContrast = MediaQuery.maybeHighContrastOf(context) ?? false; + + const CupertinoDynamicColor green = CupertinoColors.systemGreen; + final Color activeColor = + widget.activeTrackColor ?? + (isDark + ? (highContrast ? green.darkHighContrastColor : green.darkColor) + : (highContrast ? green.highContrastColor : green.color)); + final Color inactiveColor = + widget.inactiveTrackColor ?? + (isDark ? _kOffTrackDark : _kOffTrackLight); + // The glass shows the cell behind it. Inside a settings list that is the + // section background. + final Color backdropColor = + context + .dependOnInheritedWidgetOfExactType() + ?.themeData + .settingsSectionBackground ?? + (isDark ? _kCellDark : _kCellLight); + final Color focusColor = + HSLColor.fromColor( + activeColor.withValues(alpha: kCupertinoFocusColorOpacity), + ) + .withLightness(kCupertinoFocusColorBrightness) + .withSaturation(kCupertinoFocusColorSaturation) + .toColor(); + + return Semantics( + toggled: widget.value, + enabled: _enabled, + onTap: _enabled ? _handleTap : null, + child: FocusableActionDetector( + enabled: _enabled, + actions: _actions, + onShowFocusHighlight: (bool value) { + if (value != _showFocusHighlight) { + setState(() => _showFocusHighlight = value); + } + }, + mouseCursor: _enabled && kIsWeb + ? SystemMouseCursors.click + : MouseCursor.defer, + child: GestureDetector( + excludeFromSemantics: true, + behavior: HitTestBehavior.opaque, + onTapDown: _enabled ? _handleTapDown : null, + onTap: _enabled ? _handleTap : null, + onTapCancel: _enabled ? _handleTapCancel : null, + onHorizontalDragDown: _enabled ? _handleDragDown : null, + onHorizontalDragStart: _enabled ? _handleDragStart : null, + onHorizontalDragUpdate: _enabled ? _handleDragUpdate : null, + onHorizontalDragEnd: _enabled ? _handleDragEnd : null, + onHorizontalDragCancel: _enabled ? _handleDragCancel : null, + child: Opacity( + opacity: _enabled ? 1 : 0.5, + child: RepaintBoundary( + child: CustomPaint( + size: const Size(_kTrackWidth, _kTrackHeight), + painter: _SwitchPainter( + position: _position, + press: _press, + trackOn: _trackOn, + tint: _tint, + activeColor: activeColor, + inactiveColor: inactiveColor, + backdropColor: backdropColor, + isDark: isDark, + textDirection: + Directionality.maybeOf(context) ?? TextDirection.ltr, + focusColor: _showFocusHighlight && _enabled + ? focusColor + : null, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _SwitchPainter extends CustomPainter { + _SwitchPainter({ + required this.position, + required this.press, + required this.trackOn, + required this.tint, + required this.activeColor, + required this.inactiveColor, + required this.backdropColor, + required this.isDark, + required this.textDirection, + required this.focusColor, + }) : super(repaint: Listenable.merge([position, press, trackOn, tint])); + + final Animation position; + final Animation press; + final Animation trackOn; + final Animation tint; + final Color activeColor; + final Color inactiveColor; + final Color backdropColor; + final bool isDark; + final TextDirection textDirection; + final Color? focusColor; + + @override + void paint(Canvas canvas, Size size) { + final Rect track = Rect.fromCenter( + center: size.center(Offset.zero), + width: _kTrackWidth, + height: _kTrackHeight, + ); + + canvas.save(); + // The look is symmetric left to right, so right-to-left layouts just + // mirror the canvas. + if (textDirection == TextDirection.rtl) { + canvas.translate(size.width, 0); + canvas.scale(-1, 1); + } + + final Color trackColor = Color.lerp( + inactiveColor, + activeColor, + trackOn.value.clamp(0.0, 1.0), + )!; + canvas.drawRSuperellipse(_capsule(track), Paint()..color = trackColor); + + if (focusColor != null) { + canvas.drawRSuperellipse( + _capsule(track.inflate(1.75)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..color = focusColor!, + ); + } + + final double p = math.max(0.0, press.value); + final Rect thumb = Rect.fromCenter( + center: Offset( + track.left + _kThumbInset + _kThumbWidth / 2 + position.value, + track.center.dy, + ), + width: _kThumbWidth + (_kLensWidth - _kThumbWidth) * p, + height: _kThumbHeight + (_kLensHeight - _kThumbHeight) * p, + ); + + // The opaque thumb hands over to the lens early in the press (and back + // late in the release). The lens is frosted while it is small. + final double glass = _smoothstep(0.04, 0.3, p); + final double thumbOpacity = 1 - _smoothstep(0.12, 0.35, p); + final double frost = 1 - _smoothstep(0.6, 1.0, p); + + if (glass > 0) { + _paintLens(canvas, track, thumb, trackColor, p, glass, frost); + } + if (thumbOpacity > 0) { + final Color fill = Color.lerp( + _kWhite, + trackColor, + tint.value.clamp(0.0, 1.0) * (isDark ? 0.5 : 0.2), + )!; + canvas.drawRSuperellipse( + _capsule(thumb), + Paint()..color = fill.withValues(alpha: fill.a * thumbOpacity), + ); + } + canvas.restore(); + } + + void _paintLens( + Canvas canvas, + Rect track, + Rect lens, + Color trackColor, + double p, + double glass, + double frost, + ) { + final double clarity = 1 - frost; + final RSuperellipse lensShape = _capsule(lens); + final double cx = lens.center.dx; + final double cy = lens.center.dy; + + // Soft shadow, below and beside the lens only. Below, it is a blurred + // band 3.5-6.5 pt under the lens edge, so it is faint right at the edge + // and darkest ~5 pt down. + final Path lensPath = Path()..addRSuperellipse(lensShape); + canvas.save(); + canvas.clipRect( + Rect.fromLTRB(lens.left - 20, cy, lens.right + 20, lens.bottom + 20), + ); + canvas.drawPath( + Path() + ..fillType = PathFillType.evenOdd + ..addPath(lensPath, const Offset(0, 3.5)) + ..addPath(lensPath, const Offset(0, 6.5)), + Paint() + ..color = _kBlack.withValues(alpha: (isDark ? 0.47 : 0.2) * glass) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), + ); + canvas.restore(); + canvas.drawRSuperellipse( + _capsule( + Rect.fromLTRB(lens.left, lens.top + 4, lens.right, lens.bottom - 4), + ), + Paint() + ..color = _kBlack.withValues(alpha: (isDark ? 0.24 : 0.1) * glass) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2), + ); + + if (glass < 1) { + canvas.saveLayer( + lens.inflate(1), + Paint()..color = _kBlack.withValues(alpha: glass), + ); + } else { + canvas.save(); + } + canvas.clipRSuperellipse(lensShape); + + // The glass minifies what is behind it: the track looks 0.76x as tall + // and its ends are pulled in. Between the rim and the track image, the + // lens shows the cell around the track as bands above and below. While + // the lens is frosted, the track shows as a narrower blurred bar. + final double halfHeight = _kTrackHeight / 2 * _lerp(0.76, 0.5, frost); + final double imageTop = cy - halfHeight; + final double imageBottom = cy + halfHeight; + double stop(double y) => ((y - lens.top) / lens.height).clamp(0.0, 1.0); + final List bands = isDark + ? [ + _mix(backdropColor, _kWhite, 0.132), + _mix(backdropColor, _kWhite, 0.057), + _mix(backdropColor, _kWhite, 0.08), + _mix(backdropColor, _kWhite, 0.101), + _mix(backdropColor, _kWhite, 0.145), + ] + : [ + _mix(backdropColor, _kBlack, 0.04), + _mix(backdropColor, _kBlack, 0.082), + _mix(backdropColor, _kBlack, 0.05), + backdropColor, + backdropColor, + ]; + canvas.drawRect( + lens, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: bands, + stops: [0, stop(imageTop), stop(cy), stop(imageBottom), 1], + ).createShader(lens), + ); + + // The rim is a stack of hairlines about 1 pt deep. It darkens what is + // under it, more at the ends than along the top and bottom, and more + // over the bands than over the track image. Along the top and bottom it + // ends in a bright line tinted by the track and a specular line. + final double r = lens.height / 2; + final double w = lens.width; + Shader ends(double midAlpha, double endAlpha) => LinearGradient( + colors: [ + _kBlack.withValues(alpha: endAlpha), + _kBlack.withValues(alpha: midAlpha), + _kBlack.withValues(alpha: midAlpha), + _kBlack.withValues(alpha: endAlpha), + ], + stops: [0, r / w, 1 - r / w, 1], + ).createShader(lens); + Shader sides(Color color) => LinearGradient( + colors: [ + color.withValues(alpha: 0), + color.withValues(alpha: 0), + color, + color, + color.withValues(alpha: 0), + color.withValues(alpha: 0), + ], + stops: [ + 0, + 0.45 * r / w, + 1.1 * r / w, + 1 - 1.1 * r / w, + 1 - 0.45 * r / w, + 1, + ], + ).createShader(lens); + void rimLine(double inset, double width, Paint paint) { + canvas.drawRSuperellipse( + _capsule(lens.deflate(inset)), + paint + ..style = PaintingStyle.stroke + ..strokeWidth = width, + ); + } + + rimLine( + 0.33, + 0.67, + Paint()..shader = ends(isDark ? 0.72 : 0.15, isDark ? 0.8 : 0.3), + ); + rimLine(0.83, 0.34, Paint()..shader = ends(0, isDark ? 0.3 : 0.15)); + + // Where the track runs on past the lens edge, the glass pulls it in + // over the whole end of the lens ("flare"). Where the track ends under + // the lens, its rounded end shows instead. + double imageLeft = cx + (track.left - cx) * 0.87; + double imageRight = cx + (track.right - cx) * 0.87; + final double frostInset = 0.3 * lens.height; + imageLeft = _lerp( + imageLeft, + math.max(imageLeft, lens.left + frostInset), + frost, + ); + imageRight = _lerp( + imageRight, + math.min(imageRight, lens.right - frostInset), + frost, + ); + final double leftFlare = + _smoothstep(-2, 1.5, lens.left - track.left) * clarity; + final double rightFlare = + _smoothstep(-2, 1.5, track.right - lens.right) * clarity; + imageLeft -= + (imageLeft - math.min(imageLeft, lens.left - halfHeight)) * leftFlare; + imageRight += + (math.max(imageRight, lens.right + halfHeight) - imageRight) * + rightFlare; + Path image = Path() + ..addRRect( + RRect.fromLTRBR( + imageLeft, + imageTop, + imageRight, + imageBottom, + Radius.circular(halfHeight), + ), + ); + final double flareWidth = 9.2 * lens.width / _kLensWidth; + if (leftFlare > 0) { + image = Path.combine( + PathOperation.union, + image, + _flare(lens, imageTop, imageBottom, leftFlare, flareWidth, left: true), + ); + } + if (rightFlare > 0) { + image = Path.combine( + PathOperation.union, + image, + _flare( + lens, + imageTop, + imageBottom, + rightFlare, + flareWidth, + left: false, + ), + ); + } + final Paint imagePaint = Paint() + ..color = _lift(trackColor, isDark ? 12 : 6); + if (frost > 0.01) { + // While the lens forms and dissolves, the track behind it is blurred. + imagePaint.maskFilter = MaskFilter.blur(BlurStyle.normal, 5 * frost); + } + canvas.drawPath(image, imagePaint); + + if (clarity > 0) { + canvas.save(); + canvas.clipPath(image); + rimLine( + 0.33, + 0.67, + Paint() + ..color = _kBlack.withValues(alpha: (isDark ? 0.5 : 0.36) * clarity), + ); + rimLine( + 0.83, + 0.34, + Paint()..shader = ends(0, (isDark ? 0.25 : 0.16) * clarity), + ); + // The track image is darker along its top edge (an inner shadow that + // fades out ~10 pt down) and toward the rim at the ends. + canvas.drawPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(lens.inflate(20)) + ..addPath(image, const Offset(0, 5)), + Paint() + ..color = _kBlack.withValues( + alpha: (isDark ? 0.215 : 0.087) * clarity, + ) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.6), + ); + final double sideAlpha = (isDark ? 0.21 : 0.095) * clarity; + const List fade = [1.0, 1.0, 0.84, 0.5, 0.16, 0.0]; + const List fadeAt = [0, 2, 5.5, 9, 12.5, 16]; + canvas.drawRect( + lens, + Paint() + ..shader = LinearGradient( + colors: [ + for (final double a in fade) + _kBlack.withValues(alpha: sideAlpha * a), + for (final double a in fade.reversed) + _kBlack.withValues(alpha: sideAlpha * a), + ], + stops: [ + for (final double d in fadeAt) d / w, + for (final double d in fadeAt.reversed) 1 - d / w, + ], + ).createShader(lens), + ); + canvas.restore(); + } + + if (frost > 0) { + // Milky white wash while the lens forms and dissolves. + canvas.drawRect( + lens, + Paint() + ..color = _kWhite.withValues(alpha: (isDark ? 0.3 : 0.5) * frost), + ); + } + + rimLine(0.8, 0.4, Paint()..shader = sides(_hairline(trackColor))); + rimLine( + 1.2, + 0.4, + Paint()..shader = sides(_kWhite.withValues(alpha: isDark ? 0.14 : 0.9)), + ); + + canvas.restore(); + } + + /// The part of the track image that fills one end of the lens, from the + /// rim to [width] inside it, with rounded corners toward the bands. + static Path _flare( + Rect lens, + double imageTop, + double imageBottom, + double amount, + double width, { + required bool left, + }) { + final double inner = width * amount; + final double radius = 3.5 * amount; + double x(double fromRim) => + left ? lens.left + fromRim : lens.right - fromRim; + final double top = lens.top - 1; + final double bottom = lens.bottom + 1; + return Path() + ..moveTo(x(inner + radius), imageTop) + ..arcToPoint( + Offset(x(inner), imageTop - radius), + radius: Radius.circular(radius), + clockwise: left, + ) + ..lineTo(x(inner), top) + ..lineTo(x(-1), top) + ..lineTo(x(-1), bottom) + ..lineTo(x(inner), bottom) + ..lineTo(x(inner), imageBottom + radius) + ..arcToPoint( + Offset(x(inner + radius), imageBottom), + radius: Radius.circular(radius), + clockwise: left, + ) + ..close(); + } + + @override + bool shouldRepaint(_SwitchPainter oldDelegate) { + return oldDelegate.position != position || + oldDelegate.press != press || + oldDelegate.trackOn != trackOn || + oldDelegate.tint != tint || + oldDelegate.activeColor != activeColor || + oldDelegate.inactiveColor != inactiveColor || + oldDelegate.backdropColor != backdropColor || + oldDelegate.isDark != isDark || + oldDelegate.textDirection != textDirection || + oldDelegate.focusColor != focusColor; + } +} + +/// A capsule with continuous (squircle-smoothed) corners, like iOS draws it. +RSuperellipse _capsule(Rect rect) => RSuperellipse.fromRectAndRadius( + rect, + Radius.circular(rect.shortestSide / 2), +); + +double _lerp(double a, double b, double t) => a + (b - a) * t; + +double _smoothstep(double edge0, double edge1, double x) { + final double t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + return t * t * (3 - 2 * t); +} + +Color _mix(Color a, Color b, double t) => Color.lerp(a, b, t)!; + +/// Brightens [color] by [levels] out of 255 on each channel, the way the +/// glass lifts what it shows. +Color _lift(Color color, double levels) { + final double d = levels / 255; + return Color.from( + alpha: color.a, + red: math.min(1.0, color.r + d), + green: math.min(1.0, color.g + d), + blue: math.min(1.0, color.b + d), + ); +} + +/// The bright rim hairline: the track color, lighter and a little more +/// saturated. Saturated colors are lifted more than greys. +Color _hairline(Color color) { + final HSVColor hsv = HSVColor.fromColor(color); + final double lift = _lerp(0.2, 0.55, hsv.saturation); + return hsv + .withValue(hsv.value + (1 - hsv.value) * lift) + .withSaturation(math.min(1.0, hsv.saturation * 1.1)) + .toColor(); +} diff --git a/lib/src/tiles/platforms/ios_settings_tile.dart b/lib/src/tiles/platforms/ios_settings_tile.dart index 4297762..c51fd68 100644 --- a/lib/src/tiles/platforms/ios_settings_tile.dart +++ b/lib/src/tiles/platforms/ios_settings_tile.dart @@ -84,13 +84,15 @@ class IOSSettingsTileState extends State { content = Material(color: Colors.transparent, child: content); } - return ClipRRect( + // Continuous (superellipse) corners, like the grouped cards in iOS + // Settings. + return ClipRSuperellipse( borderRadius: BorderRadius.vertical( top: additionalInfo.enableTopBorderRadius - ? const Radius.circular(12) + ? const Radius.circular(26) : Radius.zero, bottom: additionalInfo.enableBottomBorderRadius - ? const Radius.circular(12) + ? const Radius.circular(26) : Radius.zero, ), child: content, @@ -107,8 +109,8 @@ class IOSSettingsTileState extends State { return Container( width: MediaQuery.of(context).size.width, padding: EdgeInsets.only( - left: 18, - right: 18, + left: 16, + right: 16, top: textScaler.scale(8), bottom: additionalInfo.needToShowDivider ? 24 : textScaler.scale(8), ), @@ -146,8 +148,11 @@ class IOSSettingsTileState extends State { child: widget.trailing!, ), ), + // The switch's glass lens paints up to ~12.5pt past its end and ~6pt + // above and below it. The 16pt end padding and the 52pt row keep it + // inside the card. if (widget.tileType == SettingsTileType.switchTile) - CupertinoSwitch( + CupertinoSettingsSwitch( value: widget.initialValue ?? true, onChanged: widget.onToggle, activeTrackColor: widget.enabled @@ -217,7 +222,7 @@ class IOSSettingsTileState extends State { color: isPressed ? theme.themeData.tileHighlightColor : theme.themeData.settingsSectionBackground, - padding: const EdgeInsetsDirectional.only(start: 18), + padding: const EdgeInsetsDirectional.only(start: 16), child: Row( children: [ if (widget.leading != null) @@ -260,29 +265,31 @@ class IOSSettingsTileState extends State { Padding( padding: widget.titlePadding ?? + // 16 + 17pt text + 16 gives the + // 52pt row of iOS Settings. EdgeInsetsDirectional.only( top: textScaler.scale( - widget.compact ? 6.0 : 12.5, + widget.compact ? 8.0 : 16.0, ), bottom: widget.titleDescription == null ? textScaler.scale( widget.compact - ? 6.0 - : 12.5, + ? 8.0 + : 16.0, ) : textScaler.scale( widget.compact ? 2.0 - : 3.5, + : 4.0, ), ), child: DefaultTextStyle( style: (theme.themeData.tileTextStyle ?? const TextStyle( - fontSize: 16, + fontSize: 17, )) .copyWith( color: widget.enabled @@ -301,7 +308,9 @@ class IOSSettingsTileState extends State { padding: widget.titleDescriptionPadding ?? EdgeInsetsDirectional.only( - bottom: textScaler.scale(12.5), + bottom: textScaler.scale( + widget.compact ? 8.0 : 16.0, + ), ), child: DefaultTextStyle( style: TextStyle( diff --git a/lib/src/tiles/platforms/web_settings_tile.dart b/lib/src/tiles/platforms/web_settings_tile.dart index d5fecae..4f5c9b4 100644 --- a/lib/src/tiles/platforms/web_settings_tile.dart +++ b/lib/src/tiles/platforms/web_settings_tile.dart @@ -63,108 +63,139 @@ class WebSettingsTile extends StatelessWidget { } }, highlightColor: theme.themeData.tileHighlightColor, - child: Row( - children: [ - if (leading != null) - Padding( - padding: - leadingPadding ?? - const EdgeInsetsDirectional.only(start: 24), - child: IconTheme( - data: IconTheme.of(context).copyWith( - color: enabled - ? theme.themeData.leadingIconsColor - : theme.themeData.inactiveTitleColor, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: textScaler.scale(48)), + child: Row( + children: [ + if (leading != null) + Padding( + padding: + leadingPadding ?? + const EdgeInsetsDirectional.only(start: 20), + child: IconTheme( + data: IconTheme.of(context).copyWith( + color: enabled + ? theme.themeData.leadingIconsColor + : theme.themeData.inactiveTitleColor, + size: 20, + ), + child: leading!, ), - child: leading!, ), - ), - Expanded( - child: Padding( - padding: EdgeInsetsDirectional.only( - start: 24, - end: 24, - bottom: textScaler.scale(compact ? 9 : 19), - top: textScaler.scale(compact ? 9 : 19), + Expanded( + child: Padding( + padding: EdgeInsetsDirectional.only( + start: leading != null ? 16 : 20, + end: 20, + bottom: textScaler.scale(compact ? 6 : 12), + top: textScaler.scale(compact ? 6 : 12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: + (theme.themeData.tileTextStyle ?? + const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + )) + .copyWith( + color: enabled + ? theme.themeData.settingsTileTextColor + : theme.themeData.inactiveTitleColor, + ), + child: title ?? Container(), + ), + if (value != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: DefaultTextStyle( + style: + (theme.themeData.tileDescriptionTextStyle ?? + const TextStyle(fontSize: 13)) + .copyWith( + color: enabled + ? theme + .themeData + .tileDescriptionTextColor + : theme + .themeData + .inactiveSubtitleColor, + ), + child: value!, + ), + ) + else if (description != null) + Padding( + padding: + descriptionPadding ?? + const EdgeInsets.only(top: 4.0), + child: DefaultTextStyle( + style: + (theme.themeData.tileDescriptionTextStyle ?? + const TextStyle(fontSize: 13)) + .copyWith( + color: enabled + ? theme + .themeData + .tileDescriptionTextColor + : theme + .themeData + .inactiveSubtitleColor, + ), + child: description!, + ), + ), + ], + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + if (trailing != null && tileType == SettingsTileType.switchTile) + Row( children: [ - DefaultTextStyle( - style: - (theme.themeData.tileTextStyle ?? - const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w400, - )) - .copyWith( - color: enabled - ? theme.themeData.settingsTileTextColor - : theme.themeData.inactiveTitleColor, - ), - child: title ?? Container(), + IconTheme( + data: IconTheme.of(context).copyWith( + color: enabled + ? theme.themeData.leadingIconsColor + : theme.themeData.inactiveTitleColor, + ), + child: trailing!, ), - if (value != null) - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: DefaultTextStyle( - style: - (theme.themeData.tileDescriptionTextStyle ?? - const TextStyle()) - .copyWith( - color: enabled - ? theme - .themeData - .tileDescriptionTextColor - : theme - .themeData - .inactiveSubtitleColor, - ), - child: value!, - ), - ) - else if (description != null) - Padding( - padding: - descriptionPadding ?? - const EdgeInsets.only(top: 4.0), - child: DefaultTextStyle( - style: - (theme.themeData.tileDescriptionTextStyle ?? - const TextStyle()) - .copyWith( - color: enabled - ? theme - .themeData - .tileDescriptionTextColor - : theme - .themeData - .inactiveSubtitleColor, - ), - child: description!, - ), + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: Switch( + activeThumbColor: enabled + ? (activeSwitchColor ?? + const Color.fromRGBO(138, 180, 248, 1.0)) + : theme.themeData.inactiveTitleColor, + value: initialValue, + onChanged: onToggle, ), + ), ], - ), - ), - ), - // if (tileType == SettingsTileType.navigationTile) - // Padding( - // padding: - // const EdgeInsetsDirectional.only(start: 6, end: 15), - // child: IconTheme( - // data: IconTheme.of(context) - // .copyWith(color: theme.themeData.leadingIconsColor), - // child: Icon( - // CupertinoIcons.chevron_forward, - // size: 18 * scaleFactor, - // ), - // ), - // ), - if (trailing != null && tileType == SettingsTileType.switchTile) - Row( - children: [ - IconTheme( + ) + else if (tileType == SettingsTileType.switchTile) + Padding( + padding: const EdgeInsetsDirectional.only( + start: 16, + end: 8, + ), + child: Switch( + value: initialValue, + activeThumbColor: !enabled + ? (theme.themeData.inactiveSwitchColor ?? + theme.themeData.inactiveTitleColor) + : activeSwitchColor, + onChanged: onToggle, + ), + ) + else if (trailing != null) + Padding( + padding: + trailingPadding ?? + const EdgeInsets.symmetric(horizontal: 16), + child: IconTheme( data: IconTheme.of(context).copyWith( color: enabled ? theme.themeData.leadingIconsColor @@ -172,46 +203,28 @@ class WebSettingsTile extends StatelessWidget { ), child: trailing!, ), - Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: Switch( - activeThumbColor: enabled - ? (activeSwitchColor ?? - const Color.fromRGBO(138, 180, 248, 1.0)) + ), + // Chrome shows a chevron on rows that open a sub-page. + if (tileType == SettingsTileType.navigationTile) + Padding( + padding: const EdgeInsetsDirectional.only( + start: 8, + end: 16, + ), + child: IconTheme( + data: IconTheme.of(context).copyWith( + color: enabled + ? theme.themeData.leadingIconsColor : theme.themeData.inactiveTitleColor, - value: initialValue, - onChanged: onToggle, + ), + child: Icon( + Icons.chevron_right, + size: textScaler.scale(20), ), ), - ], - ) - else if (tileType == SettingsTileType.switchTile) - Padding( - padding: const EdgeInsetsDirectional.only(start: 16, end: 8), - child: Switch( - value: initialValue, - activeThumbColor: !enabled - ? (theme.themeData.inactiveSwitchColor ?? - theme.themeData.inactiveTitleColor) - : activeSwitchColor, - onChanged: onToggle, - ), - ) - else if (trailing != null) - Padding( - padding: - trailingPadding ?? - const EdgeInsets.symmetric(horizontal: 16), - child: IconTheme( - data: IconTheme.of(context).copyWith( - color: enabled - ? theme.themeData.leadingIconsColor - : theme.themeData.inactiveTitleColor, - ), - child: trailing!, ), - ), - ], + ], + ), ), ), ), diff --git a/lib/src/utils/theme_provider.dart b/lib/src/utils/theme_provider.dart index 484e087..54b9ad2 100644 --- a/lib/src/utils/theme_provider.dart +++ b/lib/src/utils/theme_provider.dart @@ -28,9 +28,8 @@ class ThemeProvider { } } - /// Derives Material 3 colors from the active [ColorScheme]. - /// Falls back to hardcoded values when a color is not available in the - /// scheme, ensuring backwards compatibility with Material 2 apps. + /// Derives Material 3 colors from the active [ColorScheme], like Android + /// 16+ settings: a tinted page with lighter cards for the tiles. static SettingsThemeData _androidTheme({ required BuildContext context, required Brightness brightness, @@ -38,9 +37,10 @@ class ThemeProvider { final colorScheme = Theme.of(context).colorScheme; final isLight = brightness == Brightness.light; - final listBackground = isLight - ? colorScheme.surfaceContainerLow - : colorScheme.surfaceContainerLow; + final listBackground = colorScheme.surfaceContainer; + final sectionBackground = isLight + ? colorScheme.surfaceBright + : colorScheme.surfaceContainerHighest; final titleTextColor = colorScheme.primary; @@ -59,6 +59,7 @@ class ThemeProvider { return SettingsThemeData( tileHighlightColor: tileHighlightColor, settingsListBackground: listBackground, + settingsSectionBackground: sectionBackground, titleTextColor: titleTextColor, settingsTileTextColor: settingsTileTextColor, tileDescriptionTextColor: tileDescriptionTextColor, @@ -80,7 +81,7 @@ class ThemeProvider { const lightSettingSectionColor = CupertinoColors.white; const darkSettingSectionColor = Color.fromARGB(255, 28, 28, 30); - const lightSettingsTitleColor = Color.fromRGBO(109, 109, 114, 1); + const lightSettingsTitleColor = Color.fromRGBO(60, 60, 67, 0.6); const darkSettingsTitleColor = CupertinoColors.systemGrey; const lightDividerColor = Color.fromARGB(255, 238, 238, 238); @@ -128,17 +129,22 @@ class ThemeProvider { ); } - /// Web theme mirrors the Android Material 3 derivation but also sets - /// [settingsSectionBackground] for the card background. + /// Web theme follows desktop Chrome settings: white page and cards (the + /// card shadow separates them) and near-black section titles. static SettingsThemeData _webTheme({ required BuildContext context, required Brightness brightness, }) { final colorScheme = Theme.of(context).colorScheme; + final isLight = brightness == Brightness.light; - final listBackground = colorScheme.surfaceContainerLow; - final sectionBackground = colorScheme.surface; - final titleTextColor = colorScheme.primary; + final listBackground = isLight + ? colorScheme.surfaceContainerLowest + : colorScheme.surface; + final sectionBackground = isLight + ? colorScheme.surfaceContainerLowest + : colorScheme.surfaceContainerLow; + final titleTextColor = colorScheme.onSurface; final settingsTileTextColor = colorScheme.onSurface; final tileDescriptionTextColor = colorScheme.onSurfaceVariant; final leadingIconsColor = colorScheme.onSurfaceVariant; diff --git a/test/settings_tests/app_theme_tests.dart b/test/settings_tests/app_theme_tests.dart index 17bc5e3..2e80243 100644 --- a/test/settings_tests/app_theme_tests.dart +++ b/test/settings_tests/app_theme_tests.dart @@ -48,19 +48,46 @@ void appThemeTests() { brightness: brightness, ); - for (final platform in [DevicePlatform.android, DevicePlatform.web]) { - testWidgets('$platform, $brightness: colors follow the app ColorScheme', ( - tester, - ) async { - await tester.pumpWidget(_materialApp(brightness, platform: platform)); - - final data = _settingsTheme(tester).themeData; - expect(data.settingsListBackground, scheme.surfaceContainerLow); - expect(data.titleTextColor, scheme.primary); - expect(data.settingsTileTextColor, scheme.onSurface); - expect(data.leadingIconsColor, scheme.onSurfaceVariant); - }); - } + final isLight = brightness == Brightness.light; + + testWidgets('Android, $brightness: colors follow the app ColorScheme', ( + tester, + ) async { + await tester.pumpWidget( + _materialApp(brightness, platform: DevicePlatform.android), + ); + + final data = _settingsTheme(tester).themeData; + expect(data.settingsListBackground, scheme.surfaceContainer); + expect( + data.settingsSectionBackground, + isLight ? scheme.surfaceBright : scheme.surfaceContainerHighest, + ); + expect(data.titleTextColor, scheme.primary); + expect(data.settingsTileTextColor, scheme.onSurface); + expect(data.leadingIconsColor, scheme.onSurfaceVariant); + }); + + testWidgets('Web, $brightness: colors follow the app ColorScheme', ( + tester, + ) async { + await tester.pumpWidget( + _materialApp(brightness, platform: DevicePlatform.web), + ); + + final data = _settingsTheme(tester).themeData; + expect( + data.settingsListBackground, + isLight ? scheme.surfaceContainerLowest : scheme.surface, + ); + expect( + data.settingsSectionBackground, + isLight ? scheme.surfaceContainerLowest : scheme.surfaceContainerLow, + ); + expect(data.titleTextColor, scheme.onSurface); + expect(data.settingsTileTextColor, scheme.onSurface); + expect(data.leadingIconsColor, scheme.onSurfaceVariant); + }); } testWidgets('ThemeData.platform picks the tile style', (tester) async { diff --git a/test/settings_tests/cupertino_switch_tests.dart b/test/settings_tests/cupertino_switch_tests.dart new file mode 100644 index 0000000..e3c588b --- /dev/null +++ b/test/settings_tests/cupertino_switch_tests.dart @@ -0,0 +1,509 @@ +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:settings_ui/settings_ui.dart'; + +/// Tests for [CupertinoSettingsSwitch], the iOS 26+ "Liquid Glass" switch. + +const Color _white = Color(0xFFFFFFFF); + +final Finder _switch = find.byType(CupertinoSettingsSwitch); + +/// Pumps a switch that keeps its own value, like an app would, and returns +/// the list of values passed to `onChanged`. +Future> _pumpSwitch( + WidgetTester tester, { + bool value = false, + bool enabled = true, + TextDirection textDirection = TextDirection.ltr, + bool disableAnimations = false, + Brightness brightness = Brightness.light, + Color? activeTrackColor, + Color? inactiveTrackColor, +}) async { + final changes = []; + var current = value; + await tester.pumpWidget( + CupertinoApp( + // A fresh switch on every call. + key: UniqueKey(), + theme: CupertinoThemeData(brightness: brightness), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: Directionality(textDirection: textDirection, child: child!), + ), + home: Center( + child: StatefulBuilder( + builder: (context, setState) => CupertinoSettingsSwitch( + value: current, + onChanged: enabled + ? (value) { + changes.add(value); + setState(() => current = value); + } + : null, + activeTrackColor: activeTrackColor, + inactiveTrackColor: inactiveTrackColor, + ), + ), + ), + ), + ); + return changes; +} + +bool _value(WidgetTester tester) => + tester.widget(_switch).value; + +/// The center of the resting thumb. +Offset _thumb(WidgetTester tester, {bool on = false, bool rtl = false}) { + final rect = tester.getRect(_switch); + final fromStart = 20.5 + (on ? 22 : 0); + return Offset( + rtl ? rect.right - fromStart : rect.left + fromStart, + rect.center.dy, + ); +} + +RenderObject _painted(WidgetTester tester) => tester.renderObject( + find.descendant(of: _switch, matching: find.byType(CustomPaint)), +); + +/// The resting thumb, in the switch's own coordinates. +RSuperellipse _restingThumb({required bool on}) => + RSuperellipse.fromRectAndRadius( + Rect.fromLTWH(on ? 24 : 2, 2, 37, 24), + const Radius.circular(12), + ); + +void cupertinoSwitchTests() { + testWidgets('takes 63x28 of layout space', (tester) async { + await _pumpSwitch(tester); + + expect(tester.getSize(_switch), const Size(63, 28)); + expect(tester.takeException(), isNull); + }); + + testWidgets('draws the measured iOS 27 colors at rest', (tester) async { + await _pumpSwitch(tester); + expect( + _painted(tester), + paints + ..rsuperellipse(color: const Color(0xFFC5C5C7)) + ..rsuperellipse(rsuperellipse: _restingThumb(on: false), color: _white), + ); + + await _pumpSwitch(tester, value: true); + expect( + _painted(tester), + paints + ..rsuperellipse(color: const Color(0xFF34C759)) + ..rsuperellipse(rsuperellipse: _restingThumb(on: true), color: _white), + ); + + await _pumpSwitch(tester, brightness: Brightness.dark); + expect( + _painted(tester), + paints..rsuperellipse(color: const Color(0xFF5A5A5E)), + ); + + await _pumpSwitch(tester, value: true, brightness: Brightness.dark); + expect( + _painted(tester), + paints..rsuperellipse(color: const Color(0xFF30D158)), + ); + }); + + testWidgets('uses activeTrackColor and inactiveTrackColor', (tester) async { + const blue = Color(0xFF0A84FF); + const pink = Color(0xFFFF2D55); + await _pumpSwitch( + tester, + value: true, + activeTrackColor: blue, + inactiveTrackColor: pink, + ); + expect(_painted(tester), paints..rsuperellipse(color: blue)); + + await tester.tap(_switch); + await tester.pumpAndSettle(); + expect(_value(tester), isFalse); + expect(_painted(tester), paints..rsuperellipse(color: pink)); + }); + + testWidgets('a tap toggles the value', (tester) async { + final changes = await _pumpSwitch(tester); + + await tester.tap(_switch); + await tester.pump(); + expect(changes, [true]); + // The glass lens is showing while the thumb travels. + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.hasRunningAnimations, isTrue); + await tester.pumpAndSettle(); + expect(_value(tester), isTrue); + expect( + _painted(tester), + paints + ..rsuperellipse(color: const Color(0xFF34C759)) + ..rsuperellipse(rsuperellipse: _restingThumb(on: true), color: _white), + ); + + await tester.tap(_switch); + await tester.pumpAndSettle(); + expect(changes, [true, false]); + expect(_value(tester), isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets('a disabled switch ignores input and is half opaque', ( + tester, + ) async { + final changes = await _pumpSwitch(tester, enabled: false); + + final opacity = tester.widget( + find.descendant(of: _switch, matching: find.byType(Opacity)), + ); + expect(opacity.opacity, 0.5); + + await tester.tap(_switch); + await tester.pumpAndSettle(); + await tester.dragFrom(_thumb(tester), const Offset(40, 0)); + await tester.pumpAndSettle(); + expect(changes, isEmpty); + expect(_value(tester), isFalse); + }); + + testWidgets('dragging past the far end flips the value mid-drag', ( + tester, + ) async { + final changes = await _pumpSwitch(tester); + + final gesture = await tester.startGesture(_thumb(tester)); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(changes, isEmpty, reason: 'not past the ON end yet'); + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(); + expect(changes, [true], reason: 'flips before the finger lifts'); + + // Dragging back past the OFF end flips it back, still mid-drag. + await gesture.moveBy(const Offset(-32, 0)); + await tester.pump(); + expect(changes, [true, false]); + await gesture.moveBy(const Offset(32, 0)); + await tester.pump(); + expect(changes, [true, false, true]); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(changes, [true, false, true]); + expect(_value(tester), isTrue); + expect( + _painted(tester), + paints + ..rsuperellipse() + ..rsuperellipse(rsuperellipse: _restingThumb(on: true), color: _white), + ); + }); + + testWidgets('releasing at the midpoint, or right at the far end, does not ' + 'flip the value', (tester) async { + final changes = await _pumpSwitch(tester); + + var gesture = await tester.startGesture(_thumb(tester)); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(-9, 0)); + await tester.pump(const Duration(milliseconds: 300)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(changes, isEmpty); + expect(_value(tester), isFalse); + expect( + _painted(tester), + paints + ..rsuperellipse() + ..rsuperellipse(rsuperellipse: _restingThumb(on: false)), + ); + + gesture = await tester.startGesture(_thumb(tester)); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(2, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(changes, isEmpty); + expect(_value(tester), isFalse); + }); + + testWidgets('drags are mirrored in right-to-left layouts', (tester) async { + final changes = await _pumpSwitch(tester, textDirection: TextDirection.rtl); + + // Dragging right (toward OFF) does nothing. + await tester.dragFrom(_thumb(tester, rtl: true), const Offset(40, 0)); + await tester.pumpAndSettle(); + expect(changes, isEmpty); + + await tester.dragFrom(_thumb(tester, rtl: true), const Offset(-40, 0)); + await tester.pumpAndSettle(); + expect(changes, [true]); + expect(_value(tester), isTrue); + + await tester.dragFrom( + _thumb(tester, on: true, rtl: true), + const Offset(40, 0), + ); + await tester.pumpAndSettle(); + expect(changes, [true, false]); + }); + + testWidgets('a vertical drag that starts on the switch scrolls the list', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + final changes = []; + await tester.pumpWidget( + CupertinoApp( + home: ListView( + controller: controller, + children: [ + const SizedBox(height: 100), + Center( + child: CupertinoSettingsSwitch( + value: false, + onChanged: changes.add, + ), + ), + const SizedBox(height: 2000), + ], + ), + ), + ); + + await tester.drag(_switch, const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(controller.offset, greaterThan(200)); + expect(changes, isEmpty); + + // A drag that wobbles sideways first, but less than the touch slop, + // still scrolls once it goes vertical. + controller.jumpTo(0); + await tester.pump(); + final gesture = await tester.startGesture(_thumb(tester)); + await tester.pump(const Duration(milliseconds: 150)); + await gesture.moveBy(const Offset(8, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(0, -30)); + await tester.pump(); + await gesture.moveBy(const Offset(0, -170)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.offset, greaterThan(150)); + expect(changes, isEmpty); + }); + + testWidgets('is a toggle for accessibility, not a button', (tester) async { + final handle = tester.ensureSemantics(); + final changes = await _pumpSwitch(tester); + + expect( + tester.getSemantics(_switch), + isSemantics( + hasToggledState: true, + isToggled: false, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + isButton: false, + ), + ); + + tester.semantics.tap(find.semantics.byAction(SemanticsAction.tap)); + await tester.pumpAndSettle(); + expect(changes, [true]); + expect( + tester.getSemantics(_switch), + isSemantics(isToggled: true, isEnabled: true), + ); + + await _pumpSwitch(tester, enabled: false); + expect( + tester.getSemantics(_switch), + isSemantics( + hasToggledState: true, + hasEnabledState: true, + isEnabled: false, + hasTapAction: false, + ), + ); + handle.dispose(); + }); + + testWidgets('Space toggles a focused switch', (tester) async { + final changes = await _pumpSwitch(tester); + + Focus.of( + tester.element( + find.descendant(of: _switch, matching: find.byType(CustomPaint)), + ), + ).requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(changes, [true]); + }); + + testWidgets('with reduced motion, there is no lens and no animation', ( + tester, + ) async { + final changes = await _pumpSwitch(tester, disableAnimations: true); + + final gesture = await tester.startGesture(_thumb(tester)); + await tester.pump(const Duration(milliseconds: 200)); + // Still just a track and a white thumb: no lens. + expect(_painted(tester), paintsExactlyCountTimes(#drawRSuperellipse, 2)); + await gesture.up(); + await tester.pump(); + expect(changes, [true]); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + expect( + _painted(tester), + paints + ..rsuperellipse(color: const Color(0xFF34C759)) + ..rsuperellipse(rsuperellipse: _restingThumb(on: true), color: _white), + ); + + await tester.dragFrom(_thumb(tester, on: true), const Offset(-40, 0)); + await tester.pump(); + expect(changes, [true, false]); + await tester.pump(); + expect(tester.hasRunningAnimations, isFalse); + expect( + _painted(tester), + paints + ..rsuperellipse() + ..rsuperellipse(rsuperellipse: _restingThumb(on: false)), + ); + }); + + testWidgets( + 'plays a light haptic on each change on iOS', + (tester) async { + final haptics = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') { + haptics.add(call.arguments); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await _pumpSwitch(tester); + + await tester.tap(_switch); + await tester.pumpAndSettle(); + await tester.dragFrom(_thumb(tester, on: true), const Offset(-40, 0)); + await tester.pumpAndSettle(); + + final isApple = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + expect( + haptics, + isApple + ? [ + 'HapticFeedbackType.lightImpact', + 'HapticFeedbackType.lightImpact', + ] + : isEmpty, + ); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + + testWidgets('iOS switch tiles use it, and the lens stays inside the card', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + var value = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => SettingsList( + platform: DevicePlatform.iOS, + sections: [ + SettingsSection( + tiles: [ + SettingsTile.switchTile( + title: const Text('Wi-Fi'), + initialValue: value, + onToggle: (newValue) => setState(() => value = newValue), + ), + ], + ), + ], + ), + ), + ), + ), + ); + + final switchRect = tester.getRect(_switch); + final card = tester.getRect(find.byType(ClipRSuperellipse)); + expect(switchRect.size, const Size(63, 28)); + expect(card.right - switchRect.right, 16); + + // Worst case: the lens at the peak of the press spring (41.7pt tall) + // and dragged as far as it goes (12.5pt past the track end). + const maxOverhang = 12.5; + const maxHalfHeight = 41.7 / 2; + expect(switchRect.right + maxOverhang, lessThan(card.right)); + expect(switchRect.center.dy - maxHalfHeight, greaterThan(card.top)); + expect(switchRect.center.dy + maxHalfHeight, lessThan(card.bottom)); + // The lens's round end must also stay inside the card's 26pt corners. + final capCenter = Offset( + switchRect.right + maxOverhang - maxHalfHeight, + switchRect.center.dy, + ); + for (final corner in [ + Offset(card.right - 26, card.top + 26), + Offset(card.right - 26, card.bottom - 26), + ]) { + expect((capCenter - corner).distance + maxHalfHeight, lessThan(26)); + } + + final gesture = await tester.startGesture(_thumb(tester)); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(const Duration(milliseconds: 500)); + expect(value, isTrue); + await gesture.up(); + await tester.pumpAndSettle(); + expect(value, isTrue); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/settings_tests/native_look_tests.dart b/test/settings_tests/native_look_tests.dart new file mode 100644 index 0000000..68b3535 --- /dev/null +++ b/test/settings_tests/native_look_tests.dart @@ -0,0 +1,165 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:settings_ui/settings_ui.dart'; + +/// Tests for the 2026 native look: Android 16+ grouped cards, iOS 26+ +/// rounded cards, and Chrome-style web cards. + +Future _pump( + WidgetTester tester, + DevicePlatform platform, + List tiles, { + Size size = const Size(400, 900), + Widget? title, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SettingsList( + platform: platform, + sections: [SettingsSection(title: title, tiles: tiles)], + ), + ), + ), + ); +} + +List _tiles(int count) => [ + for (var i = 0; i < count; i++) SettingsTile(title: Text('Tile $i')), +]; + +void nativeLookTests() { + testWidgets('Android: each tile is a card, rounder at the group ends', ( + tester, + ) async { + await _pump(tester, DevicePlatform.android, _tiles(3)); + + final clips = tester + .widgetList(find.byType(ClipRRect)) + .map((clip) => clip.borderRadius) + .toList(); + expect(clips, [ + const BorderRadius.vertical( + top: Radius.circular(20), + bottom: Radius.circular(4), + ), + const BorderRadius.all(Radius.circular(4)), + const BorderRadius.vertical( + top: Radius.circular(4), + bottom: Radius.circular(20), + ), + ]); + + final first = tester.getRect(find.byType(ClipRRect).at(0)); + final second = tester.getRect(find.byType(ClipRRect).at(1)); + expect(second.top - first.bottom, 2); + expect(first.left, 16); + }); + + testWidgets('Android: cards use the section background color', ( + tester, + ) async { + await _pump(tester, DevicePlatform.android, _tiles(1)); + + final context = tester.element(find.text('Tile 0')); + final box = tester.widget( + find + .ancestor(of: find.text('Tile 0'), matching: find.byType(ColoredBox)) + .first, + ); + expect( + box.color, + SettingsTheme.of(context).themeData.settingsSectionBackground, + ); + }); + + testWidgets('Android: switches show a check or a cross on the thumb', ( + tester, + ) async { + await _pump(tester, DevicePlatform.android, [ + SettingsTile.switchTile( + title: const Text('On'), + initialValue: true, + onToggle: (_) {}, + ), + SettingsTile.switchTile( + title: const Text('Off'), + initialValue: false, + onToggle: (_) {}, + ), + ]); + + final switches = tester.widgetList(find.byType(Switch)); + expect(switches, hasLength(2)); + for (final widget in switches) { + expect( + widget.thumbIcon?.resolve({WidgetState.selected})?.icon, + Icons.check, + ); + expect(widget.thumbIcon?.resolve({})?.icon, Icons.close); + } + }); + + testWidgets('iOS: cards have 26pt continuous corners and 20pt margins', ( + tester, + ) async { + await _pump(tester, DevicePlatform.iOS, _tiles(1)); + + final clip = tester.widget( + find.byType(ClipRSuperellipse), + ); + expect(clip.borderRadius, BorderRadius.circular(26)); + expect(tester.getRect(find.byType(ClipRSuperellipse)).left, 20); + }); + + testWidgets('iOS: section headers are 17pt semibold', (tester) async { + await _pump( + tester, + DevicePlatform.iOS, + _tiles(1), + title: const Text('Vision'), + ); + + final style = tester + .widget( + find + .ancestor( + of: find.text('Vision'), + matching: find.byType(DefaultTextStyle), + ) + .first, + ) + .style; + expect(style.fontSize, 17); + expect(style.fontWeight, FontWeight.w600); + }); + + testWidgets('Web: navigation tiles end with a chevron, simple tiles do not', ( + tester, + ) async { + await _pump(tester, DevicePlatform.web, [ + SettingsTile.navigation(title: const Text('Security')), + SettingsTile(title: const Text('Version')), + ]); + + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + final chevron = tester.getRect(find.byIcon(Icons.chevron_right)); + final security = tester.getRect(find.text('Security')); + expect(chevron.center.dy, moreOrLessEquals(security.center.dy, epsilon: 1)); + }); + + testWidgets('Web: content is a 680px column on wide screens', (tester) async { + await _pump( + tester, + DevicePlatform.web, + _tiles(1), + size: const Size(1280, 900), + ); + + expect(tester.getSize(find.byType(Card)).width, 680); + }); +} diff --git a/test/settings_tests/setting_tile_tests.dart b/test/settings_tests/setting_tile_tests.dart index 144f31a..cd969a2 100644 --- a/test/settings_tests/setting_tile_tests.dart +++ b/test/settings_tests/setting_tile_tests.dart @@ -84,13 +84,13 @@ void settingsTileTests(DevicePlatform platform) { platform == DevicePlatform.linux || platform == DevicePlatform.web) { expect(find.byType(Switch), findsWidgets); - expect(find.byType(CupertinoSwitch), findsNothing); + expect(find.byType(CupertinoSettingsSwitch), findsNothing); } if (platform == DevicePlatform.iOS || platform == DevicePlatform.macOS || platform == DevicePlatform.windows) { expect(find.byType(Switch), findsNothing); - expect(find.byType(CupertinoSwitch), findsWidgets); + expect(find.byType(CupertinoSettingsSwitch), findsWidgets); } }); diff --git a/test/settings_tests/settings_tile_on_tap_tests.dart b/test/settings_tests/settings_tile_on_tap_tests.dart index 079c1cd..a5b7ff4 100644 --- a/test/settings_tests/settings_tile_on_tap_tests.dart +++ b/test/settings_tests/settings_tile_on_tap_tests.dart @@ -1,4 +1,3 @@ -import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:settings_ui/settings_ui.dart'; @@ -52,7 +51,7 @@ void settingsTileOnTapTests(DevicePlatform platform) { if (platform == DevicePlatform.iOS || platform == DevicePlatform.macOS || platform == DevicePlatform.windows) { - await tester.tap(find.byType(CupertinoSwitch)); + await tester.tap(find.byType(CupertinoSettingsSwitch)); } else { await tester.tap(find.byType(Switch)); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 8299190..7477a8b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -6,7 +6,9 @@ import 'package:settings_ui/src/tiles/platforms/android_settings_tile.dart'; import 'settings_tests/app_theme_tests.dart'; import 'settings_tests/bug_fix_tests.dart'; +import 'settings_tests/cupertino_switch_tests.dart'; import 'settings_tests/ios_value_layout_tests.dart'; +import 'settings_tests/native_look_tests.dart'; import 'settings_tests/setting_tile_tests.dart'; import 'settings_tests/settings_list_tests.dart'; import 'settings_tests/settings_sections_tests.dart'; @@ -56,7 +58,7 @@ void main() { ); expect(titleWidget.style.color, colorScheme.onSurface); - expect(titleWidget.style.fontSize, 18); + expect(titleWidget.style.fontSize, 16); expect(titleWidget.style.fontWeight, FontWeight.w400); }); @@ -171,6 +173,14 @@ void main() { iosValueLayoutTests(); }); + group('Native look (2026)', () { + nativeLookTests(); + }); + + group('CupertinoSettingsSwitch (iOS 26+ switch)', () { + cupertinoSwitchTests(); + }); + group('Settings tile on Tap tests for different platforms', () { settingsTileOnTapTests(DevicePlatform.android); settingsTileOnTapTests(DevicePlatform.fuchsia);