diff --git a/lib/common_widgets/cupertino_sheet.dart b/lib/common_widgets/cupertino_sheet.dart new file mode 100644 index 0000000..94205ed --- /dev/null +++ b/lib/common_widgets/cupertino_sheet.dart @@ -0,0 +1,378 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:sheet/route.dart'; +import 'package:sheet/sheet.dart'; + +/// Value extracted from the official sketch iOS UI kit +/// It is the top offset that will be displayed from the bottom route +const double _kPreviousRouteVisibleOffset = 10; + +/// Value extracted from the official sketch iOS UI kit +const Radius _kCupertinoSheetTopRadius = Radius.circular(10); + +/// Estimated Round corners for iPhone X, XR, 11, 11 Pro +/// https://kylebashour.com/posts/finding-the-real-iphone-x-corner-radius +/// It used to animate the bottom route with a top radius that matches +/// the frame radius. If the device doesn't have round corners it will use +/// Radius.zero +const Radius _kRoundedDeviceRadius = Radius.circular(38.5); + +/// Minimal distance from the top of the screen to the top of the previous route +/// It will be used ff the top safe area is less than this value. +/// In iPhones the top SafeArea is more or equal to this distance. +const double _kSheetMinimalOffset = 10; + +/// Value extracted from the official sketch iOS UI kit for iPhone X, XR, 11, +/// The status bar height is bigger for devices with rounded corners, this is +/// used to detect if an iPhone has round corners or not +const double _kRoundedDeviceStatusBarHeight = 20; + +const Curve _kCupertinoSheetCurve = Curves.easeOutExpo; +const Curve _kCupertinoTransitionCurve = Curves.linear; + +/// Wraps the child into a cupertino modal sheet appearance. This is used to +/// create a [SheetRoute]. +/// +/// Clip the child widget to rectangle with top rounded corners and adds +/// top padding and top safe area. +class _CupertinoSheetDecorationBuilder extends StatelessWidget { + const _CupertinoSheetDecorationBuilder({ + required this.child, + required this.topRadius, + this.backgroundColor, + }); + + /// The child contained by the modal sheet + final Widget child; + + /// The color to paint behind the child + final Color? backgroundColor; + + /// The top corners of this modal sheet are rounded by this Radius + final Radius topRadius; + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.paddingOf(context).top; + return SafeArea( + bottom: false, + minimum: EdgeInsets.only(top: topPadding + _kPreviousRouteVisibleOffset), + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: Builder( + builder: (BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical(top: topRadius), + color: backgroundColor ?? + CupertinoColors.systemBackground.resolveFrom(context), + ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: child, + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +/// A modal route that overlays a widget over the current route and animates +/// it from the bottom with a cupertino modal sheet appearance +/// +/// Clip the child widget to rectangle with top rounded corners and adds +/// top padding and top safe area. +/// +/// * [AppCupertinoSheetPage], which is the [Page] version of this class +class CupertinoSheetRoute extends SheetRoute { + CupertinoSheetRoute({ + required WidgetBuilder builder, + super.stops, + double initialStop = 1, + super.settings, + Color? backgroundColor, + super.maintainState = true, + super.draggable = true, + super.fit, + }) : super( + builder: (BuildContext context) { + return _CupertinoSheetDecorationBuilder( + backgroundColor: backgroundColor, + topRadius: _kCupertinoSheetTopRadius, + child: Builder(builder: builder), + ); + }, + animationCurve: _kCupertinoSheetCurve, + initialExtent: initialStop, + ); + + final SheetController _sheetController = SheetController(); + + @override + SheetController createSheetController() { + return _sheetController; + } + + @override + Color? get barrierColor => Colors.transparent; + + @override + bool get barrierDismissible => true; + + @override + Widget buildSheet(BuildContext context, Widget child) { + SheetPhysics? effectivePhysics = BouncingSheetPhysics( + parent: SnapSheetPhysics( + stops: stops ?? [0, 1], + parent: physics, + ), + ); + if (!draggable) { + effectivePhysics = const NeverDraggableSheetPhysics(); + } + final size = MediaQuery.sizeOf(context); + final topPadding = MediaQuery.paddingOf(context).top; + final topMargin = math.max(_kSheetMinimalOffset, topPadding) + + _kPreviousRouteVisibleOffset; + return Sheet.raw( + initialExtent: initialExtent, + decorationBuilder: decorationBuilder, + fit: fit, + maxExtent: size.height - topMargin, + physics: effectivePhysics, + controller: sheetController, + child: child, + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final topPadding = MediaQuery.paddingOf(context).top; + final double topOffset = math.max(_kSheetMinimalOffset, topPadding); + return AnimatedBuilder( + animation: secondaryAnimation, + child: CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: child, + ), + builder: (BuildContext context, Widget? child) { + final progress = secondaryAnimation.value; + final scale = 1 - progress / 10; + final distanceWithScale = + (topOffset + _kPreviousRouteVisibleOffset) * 0.9; + final offset = Offset(0, progress * (topOffset - distanceWithScale)); + return Transform.translate( + offset: offset, + child: Transform.scale( + scale: scale, + alignment: Alignment.topCenter, + child: child, + ), + ); + }, + ); + } + + @override + bool canDriveSecondaryTransitionForPreviousRoute( + Route previousRoute, + ) { + return previousRoute is! CupertinoSheetRoute; + } + + @override + Widget buildSecondaryTransitionForPreviousRoute( + BuildContext context, + Animation secondaryAnimation, + Widget child, + ) { + final Animation delayAnimation = CurvedAnimation( + parent: _sheetController.animation, + curve: Interval( + initialExtent == 1 ? 0 : initialExtent, + 1, + ), + ); + + final Animation secondaryAnimation = CurvedAnimation( + parent: _sheetController.animation, + curve: Interval(0, initialExtent), + ); + + return CupertinoSheetBottomRouteTransition( + body: child, + sheetAnimation: delayAnimation, + secondaryAnimation: secondaryAnimation, + ); + } +} + +/// Animation for previous route when a [CupertinoSheetRoute] enters/exits +class CupertinoSheetBottomRouteTransition extends StatelessWidget { + const CupertinoSheetBottomRouteTransition({ + required this.sheetAnimation, + required this.secondaryAnimation, + required this.body, + super.key, + }); + + final Widget body; + + final Animation sheetAnimation; + final Animation secondaryAnimation; + + // Currently iOS does not provide any way to detect the radius of the + // screen device. Right not we detect if the safe area has the size + // for the device that contain a notch as they are the ones right + // now that has corners with radius + Radius _getRadiusForDevice(double topPadding) { + // Round corners for iPhone devices from X to the newest version + final isRoundedDevice = defaultTargetPlatform == TargetPlatform.iOS && + topPadding > _kRoundedDeviceStatusBarHeight; + return isRoundedDevice ? _kRoundedDeviceRadius : Radius.zero; + } + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.paddingOf(context).top; + final double topOffset = math.max(_kSheetMinimalOffset, topPadding); + final deviceCorner = _getRadiusForDevice(MediaQuery.paddingOf(context).top); + + final curvedAnimation = CurvedAnimation( + parent: sheetAnimation, + curve: _kCupertinoTransitionCurve, + ); + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: AnimatedBuilder( + animation: secondaryAnimation, + child: body, + builder: (BuildContext context, Widget? child) { + final progress = curvedAnimation.value; + final scale = 1 - progress / 10; + final radius = progress == 0 + ? Radius.zero + : Radius.lerp(deviceCorner, _kCupertinoSheetTopRadius, progress)!; + return Stack( + children: [ + Container(color: CupertinoColors.black), + Transform.translate( + offset: Offset(0, progress * topOffset), + child: Transform.scale( + scale: scale, + alignment: Alignment.topCenter, + child: ClipRRect( + borderRadius: BorderRadius.vertical(top: radius), + child: ColorFiltered( + colorFilter: ColorFilter.mode( + (CupertinoTheme.brightnessOf(context) == Brightness.dark + ? CupertinoColors.inactiveGray + : Colors.black) + .withOpacity(secondaryAnimation.value * 0.1), + BlendMode.srcOver, + ), + child: child, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +/// A modal page that overlays a widget over the current route and animates +/// it from the bottom with a cupertino modal sheet appearance +/// +/// Clip the child widget to rectangle with top rounded corners and adds +/// top padding and top safe area. +/// +/// The type `T` specifies the return type of the route which can be supplied as +/// the route is popped from the stack via [Navigator.pop] by providing the +/// optional `result` argument. +/// +/// See also: +/// +/// +/// * [CupertinoSheetRoute], which is the [PageRoute] version of this class +class AppCupertinoSheetPage extends Page { + /// Creates a material page. + const AppCupertinoSheetPage({ + required this.child, + this.maintainState = true, + this.fit = SheetFit.loose, + super.key, + super.name, + super.arguments, + }); + + /// The content to be shown in the [Route] created by this page. + final Widget child; + + /// {@macro flutter.widgets.modalRoute.maintainState} + final bool maintainState; + + final SheetFit fit; + + @override + Route createRoute(BuildContext context) { + return _PageBasedCupertinoSheetRoute(page: this); + } +} + +// A page-based version of SheetRoute. +// +// This route uses the builder from the page to build its content. This ensures +// the content is up to date after page updates. +class _PageBasedCupertinoSheetRoute extends CupertinoSheetRoute { + _PageBasedCupertinoSheetRoute({ + required AppCupertinoSheetPage page, + super.stops, + super.initialStop, + super.backgroundColor, + super.maintainState, + }) : super( + settings: page, + fit: page.fit, + builder: (BuildContext context) { + return (ModalRoute.of(context)!.settings + as AppCupertinoSheetPage) + .child; + }, + ); + + AppCupertinoSheetPage get _page => settings as AppCupertinoSheetPage; + + @override + bool get maintainState => _page.maintainState; + + @override + String get debugLabel => '${super.debugLabel}(${_page.name})'; +} diff --git a/lib/extensions/intl_extension.dart b/lib/extensions/intl_extension.dart index ccbb625..0470a51 100644 --- a/lib/extensions/intl_extension.dart +++ b/lib/extensions/intl_extension.dart @@ -38,11 +38,14 @@ extension OrdinalExtension on int { final ordinal = switch (context.loc.localeName) { ('ja') => '第$formatNumberToJapanese', ('es' || 'pt') => '$thisº', - ('en') => switch (this % 10) { - 1 => '${this}st', - 2 => '${this}nd', - 3 => '${this}rd', - _ => '${this}th' + ('en') => switch (this % 100) { + 11 || 12 || 13 => '${this}th', + _ => switch (this % 10) { + 1 => '${this}st', + 2 => '${this}nd', + 3 => '${this}rd', + _ => '${this}th', + }, }, (_) => '$this', }; diff --git a/lib/features/home/widgets/adaptive_navigation_bar.dart b/lib/features/home/widgets/adaptive_navigation_bar.dart index 9476b70..1143361 100644 --- a/lib/features/home/widgets/adaptive_navigation_bar.dart +++ b/lib/features/home/widgets/adaptive_navigation_bar.dart @@ -23,24 +23,27 @@ class AdaptiveNavigationBar extends StatelessWidget { @override Widget build(BuildContext context) { if (kIsCupertino) { - return AnimatedTheme( - data: Theme.of(context), - child: AppWebPadding.only( - color: Theme.of(context).bottomNavigationBarTheme.backgroundColor, - bottom: true, - child: CupertinoTabBar( - onTap: onDestinationSelected, - currentIndex: selectedIndex, - backgroundColor: - Theme.of(context).bottomNavigationBarTheme.backgroundColor, - items: [ - for (final destination in tabDestinations) - BottomNavigationBarItem( - icon: Icon(destination.icon), - activeIcon: Icon(destination.selectedIcon), - label: destination.label(context), - ), - ], + return MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.8, + child: AnimatedTheme( + data: Theme.of(context), + child: AppWebPadding.only( + color: Theme.of(context).bottomNavigationBarTheme.backgroundColor, + bottom: true, + child: CupertinoTabBar( + onTap: onDestinationSelected, + currentIndex: selectedIndex, + backgroundColor: + Theme.of(context).bottomNavigationBarTheme.backgroundColor, + items: [ + for (final destination in tabDestinations) + BottomNavigationBarItem( + icon: Icon(destination.icon), + activeIcon: Icon(destination.selectedIcon), + label: destination.label(context), + ), + ], + ), ), ), ); diff --git a/lib/features/home/widgets/settings_modal_sheet.dart b/lib/features/home/widgets/settings_modal_sheet.dart index b12d7a5..e981c3f 100644 --- a/lib/features/home/widgets/settings_modal_sheet.dart +++ b/lib/features/home/widgets/settings_modal_sheet.dart @@ -97,7 +97,10 @@ class SettingsLanguageSection extends ConsumerWidget { isSameAsPlatform: language.isSameAsPlatform, ); }, - title: Text(language.nativeName), + title: Text( + language.nativeName, + maxLines: 2, + ), trailing: language.isSameAsPlatform ? const Icon( CupertinoIcons.device_phone_portrait, diff --git a/lib/features/home/widgets/settings_theme_section.dart b/lib/features/home/widgets/settings_theme_section.dart index 872313b..fa17c0a 100644 --- a/lib/features/home/widgets/settings_theme_section.dart +++ b/lib/features/home/widgets/settings_theme_section.dart @@ -27,7 +27,7 @@ class SettingsThemeSection extends ConsumerWidget { children: [ CupertinoListTile( backgroundColor: context.colorScheme.surface, - trailing: CupertinoSegmentedControl( + title: CupertinoSegmentedControl( padding: EdgeInsets.zero, // This represents a currently selected segmented control. groupValue: ref.watch(appThemeModeProvider), @@ -50,7 +50,6 @@ class SettingsThemeSection extends ConsumerWidget { ), }, ), - title: Text(context.loc.switchTheme), ), CupertinoListTile( backgroundColor: context.colorScheme.surface, diff --git a/lib/features/parades/parades_tab_page.dart b/lib/features/parades/parades_tab_page.dart index 47f656a..a54bded 100644 --- a/lib/features/parades/parades_tab_page.dart +++ b/lib/features/parades/parades_tab_page.dart @@ -73,7 +73,7 @@ class _ParadesTabPageState extends ConsumerState { AppAsyncSliverWidget( asyncValue: ref.watch(paradesProvider), onErrorRetry: () async => - await Future.delayed(const Duration(milliseconds: 500), () { + Future.delayed(const Duration(milliseconds: 500), () { ref.invalidate(paradesProvider); }), child: (value) => SliverCrossAxisConstrained( diff --git a/lib/features/parades/widgets/parade_item_sidebar.dart b/lib/features/parades/widgets/parade_item_sidebar.dart index 3323f1e..3fe9532 100644 --- a/lib/features/parades/widgets/parade_item_sidebar.dart +++ b/lib/features/parades/widgets/parade_item_sidebar.dart @@ -34,51 +34,56 @@ class ParadeItemSideBar extends StatelessWidget { ], ), ), - child: Column( - children: [ - Text.rich( - TextSpan( - children: [ - TextSpan( - text: '${parade.getPerformanceIcon}\n', - style: const TextStyle(fontSize: 30), - ), - if (parade.points > 0) - TextSpan( - text: parade.points.toString(), - style: context.textTheme.labelMedium, - ), - ], - ), - style: const TextStyle(fontSize: 24), - textAlign: TextAlign.center, - ), - const Spacer(), - RotatedBox( - quarterTurns: 3, - child: Text.rich( - maxLines: 1, + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.1, + child: Column( + children: [ + Text.rich( TextSpan( children: [ TextSpan( - style: context.textTheme.headlineSmall!.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w600, + text: '${parade.getPerformanceIcon}\n', + style: const TextStyle(fontSize: 30), + ), + if (parade.points > 0) + TextSpan( + text: parade.points.toString(), + style: context.textTheme.labelMedium, ), + ], + ), + style: const TextStyle(fontSize: 24), + maxLines: 2, + textAlign: TextAlign.center, + ), + Expanded( + child: RotatedBox( + quarterTurns: 3, + child: Text.rich( + maxLines: 1, + TextSpan( children: [ - if (parade.placing > 0) - TextSpan( - text: '${parade.placing.intlOrdinal(context)}' - ' ${context.loc.schoolPerformancePlace}', + TextSpan( + style: context.textTheme.headlineSmall!.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w600, ), + children: [ + if (parade.placing > 0) + TextSpan( + text: '${parade.placing.intlOrdinal(context)}' + ' ${context.loc.schoolPerformancePlace}', + ), + ], + ), ], ), - ], + ), ), ), - ), - const SizedBox(height: 8), - ], + const SizedBox(height: 8), + ], + ), ), ); } diff --git a/lib/features/schools/details/school_details_page.dart b/lib/features/schools/details/school_details_page.dart index d47c3c4..93673ed 100644 --- a/lib/features/schools/details/school_details_page.dart +++ b/lib/features/schools/details/school_details_page.dart @@ -35,8 +35,8 @@ class _SchoolDetailsPageState extends ConsumerState { @override Widget build(BuildContext context) { final school = ref.watch(selectedSchoolProvider(widget.id)); - return Column( - mainAxisSize: MainAxisSize.min, + return ListView( + shrinkWrap: true, children: [ DecoratedBox( decoration: BoxDecoration( diff --git a/lib/main.dart b/lib/main.dart index ca3eae6..8a015dd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -63,6 +63,10 @@ class _MainAppState extends ConsumerState { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], + builder: (context, child) => MediaQuery.withClampedTextScaling( + maxScaleFactor: 2, + child: child!, + ), supportedLocales: AppLocalizations.supportedLocales, locale: language?.locale, ); diff --git a/lib/router/go_router.dart b/lib/router/go_router.dart index 53cd927..a0a0a3e 100644 --- a/lib/router/go_router.dart +++ b/lib/router/go_router.dart @@ -2,6 +2,7 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../common_widgets/cupertino_sheet.dart'; import '../features/home/home_page.dart'; import '../features/home/home_page_controller.dart'; import '../features/instruments/details/instrument_details_page.dart'; @@ -113,13 +114,22 @@ GoRouter goRouter(GoRouterRef ref) { routes: [ GoRoute( path: SchoolsTabPage.path, - builder: (_, __) => PrimaryScrollController( - controller: controllers[SchoolsTabPage.tab.name]!, - child: const SchoolsTabPage(), - ), + builder: (context, state) { + return PrimaryScrollController( + controller: controllers[SchoolsTabPage.tab.name]!, + child: const SchoolsTabPage(), + ); + }, routes: [ GoRoute( path: SchoolDetailsPage.path, + pageBuilder: (context, state) { + return AppCupertinoSheetPage( + child: SchoolDetailsPage( + id: int.parse(state.pathParameters['id']!), + ), + ); + }, onExit: (context) { Future.microtask( () => ref @@ -128,13 +138,6 @@ GoRouter goRouter(GoRouterRef ref) { ); return Future.value(true); }, - pageBuilder: (context, state) { - return SheetPage( - child: SchoolDetailsPage( - id: int.parse(state.pathParameters['id']!), - ), - ); - }, ), ], ), diff --git a/pubspec.lock b/pubspec.lock index 0727f03..bd356ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -917,6 +917,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + sheet: + dependency: "direct main" + description: + name: sheet + sha256: f7619b2bd5e031f206d8c22228ef2b12794192dea25b5af0862fa5e37fe6e36d + url: "https://pub.dev" + source: hosted + version: "1.0.0" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 99949be..6b34d49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: pull_down_button: ^0.9.3 riverpod_annotation: ^3.0.0-dev.3 shared_preferences: ^2.2.2 + sheet: ^1.0.0 sliver_tools: ^0.2.12 super_sliver_list: ^0.4.1 transparent_image: ^2.0.1