From 48e9b5fc251a6fb95b7444554edb406800807c12 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 19 Oct 2023 16:25:02 -0600 Subject: [PATCH 01/36] Remove wallpaperPhoto view and related logic (saved in `feature/wallpaper-sharing` branch if needed in the future) --- lib/logic/wallpaper_logic.dart | 59 ------ lib/main.dart | 2 - lib/router.dart | 4 - .../wallpaper_photo_screen.dart | 176 ------------------ macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 40 ---- pubspec.yaml | 1 - .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - 9 files changed, 288 deletions(-) delete mode 100644 lib/logic/wallpaper_logic.dart delete mode 100644 lib/ui/screens/wallpaper_photo/wallpaper_photo_screen.dart diff --git a/lib/logic/wallpaper_logic.dart b/lib/logic/wallpaper_logic.dart deleted file mode 100644 index 558eb07c..00000000 --- a/lib/logic/wallpaper_logic.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:ui' as ui; - -import 'package:flutter/rendering.dart'; -import 'package:image_gallery_saver/image_gallery_saver.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:wonders/common_libs.dart'; -import 'package:wonders/logic/common/platform_info.dart'; -import 'package:wonders/ui/common/modals/app_modals.dart'; - -class WallPaperLogic { - /// Walks user through flow to save a Wonder Poster to their gallery - Future save(State state, RenderRepaintBoundary boundary, {required String name}) async { - // Time to create an image! - Uint8List? pngBytes = await _getPngFromBoundary(boundary); - final context = state.context, mounted = state.mounted; - if (pngBytes != null && mounted) { - bool? result = await showModal(context, - child: OkCancelModal( - msg: $strings.wallpaperModalSave, - )); - if (result == true && mounted) { - showModal(context, child: LoadingModal(msg: $strings.wallpaperModalSaving)); - if (PlatformInfo.isMobile) { - await ImageGallerySaver.saveImage(pngBytes, quality: 95, name: name); - } else { - await Future.delayed(500.ms); - } - if (state.mounted) { - Navigator.pop(context); - showModal(context, child: OkModal(msg: $strings.wallpaperModalSaveComplete)); - } - } - } - } - - Future share(BuildContext context, RenderRepaintBoundary boundary, - {required String name, String wonderName = 'Wonderous'}) async { - Uint8List? pngBytes = await _getPngFromBoundary(boundary); - if (pngBytes != null) { - final directory = (await getApplicationDocumentsDirectory()).path; - File imgFile = File('$directory/$name.png'); - await imgFile.writeAsBytes(pngBytes); - Share.shareXFiles([XFile(imgFile.path)], - subject: '$wonderName Wallpaper', text: 'Check out this $wonderName wallpaper from the Wonderous app!'); - } - } -} - -Future _getPngFromBoundary(RenderRepaintBoundary boundary) async { - ui.Image uiImage = await boundary.toImage(); - ByteData? byteData = await uiImage.toByteData(format: ui.ImageByteFormat.png); - if (byteData != null) { - return byteData.buffer.asUint8List(); - } - return null; -} diff --git a/lib/main.dart b/lib/main.dart index fcdb3f77..3ba7fa3d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,6 @@ import 'package:wonders/logic/artifact_api_logic.dart'; import 'package:wonders/logic/artifact_api_service.dart'; import 'package:wonders/logic/timeline_logic.dart'; import 'package:wonders/logic/unsplash_logic.dart'; -import 'package:wonders/logic/wallpaper_logic.dart'; import 'package:wonders/logic/wonders_logic.dart'; void main() async { @@ -79,7 +78,6 @@ SettingsLogic get settingsLogic => GetIt.I.get(); UnsplashLogic get unsplashLogic => GetIt.I.get(); ArtifactAPILogic get metAPILogic => GetIt.I.get(); CollectiblesLogic get collectiblesLogic => GetIt.I.get(); -WallPaperLogic get wallpaperLogic => GetIt.I.get(); LocaleLogic get localeLogic => GetIt.I.get(); /// Global helpers for readability diff --git a/lib/router.dart b/lib/router.dart index f5e4f07c..35ee0d67 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -9,7 +9,6 @@ import 'package:wonders/ui/screens/collection/collection_screen.dart'; import 'package:wonders/ui/screens/home/wonders_home_screen.dart'; import 'package:wonders/ui/screens/intro/intro_screen.dart'; import 'package:wonders/ui/screens/timeline/timeline_screen.dart'; -import 'package:wonders/ui/screens/wallpaper_photo/wallpaper_photo_screen.dart'; import 'package:wonders/ui/screens/wonder_details/wonders_details_screen.dart'; /// Shared paths / urls used across the app @@ -69,9 +68,6 @@ final appRouter = GoRouter( AppRoute('/maps/:type', (s) { return FullscreenMapsViewer(type: _parseWonderType(s.params['type'])); }), - AppRoute('/wallpaperPhoto/:type', (s) { - return WallpaperPhotoScreen(type: _parseWonderType(s.params['type'])); - }), ]), ], ); diff --git a/lib/ui/screens/wallpaper_photo/wallpaper_photo_screen.dart b/lib/ui/screens/wallpaper_photo/wallpaper_photo_screen.dart deleted file mode 100644 index 4a8fae95..00000000 --- a/lib/ui/screens/wallpaper_photo/wallpaper_photo_screen.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/rendering.dart'; -import 'package:wonders/common_libs.dart'; -import 'package:wonders/logic/common/platform_info.dart'; -import 'package:wonders/logic/data/wonder_data.dart'; -import 'package:wonders/ui/common/app_icons.dart'; -import 'package:wonders/ui/common/controls/checkbox.dart'; -import 'package:wonders/ui/wonder_illustrations/common/animated_clouds.dart'; -import 'package:wonders/ui/wonder_illustrations/common/wonder_illustration.dart'; -import 'package:wonders/ui/wonder_illustrations/common/wonder_illustration_config.dart'; -import 'package:wonders/ui/wonder_illustrations/common/wonder_title_text.dart'; - -class WallpaperPhotoScreen extends StatefulWidget { - const WallpaperPhotoScreen({Key? key, required this.type}) : super(key: key); - final WonderType type; - - @override - State createState() => _WallpaperPhotoScreenState(); -} - -class _WallpaperPhotoScreenState extends State { - final GlobalKey _containerKey = GlobalKey(); - Widget? _illustration; - - bool _showTitleText = true; - Timer? _photoRetryTimer; - - @override - void dispose() { - _photoRetryTimer?.cancel(); - super.dispose(); - } - - void _handleTakePhoto(BuildContext context, String wonderName) async { - final boundary = _containerKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; - if (boundary != null) { - wallpaperLogic.save(this, boundary, name: '${wonderName}_wallpaper'); - } - } - - void _handleSharePhoto(BuildContext context, String wonderName) async { - final boundary = _containerKey.currentContext!.findRenderObject() as RenderRepaintBoundary; - wallpaperLogic.share(context, boundary, name: '${wonderName}_wallpaper', wonderName: wonderName); - } - - void _handleTextToggle(bool? isActive) { - setState(() => _showTitleText = isActive ?? !_showTitleText); - } - - @override - Widget build(BuildContext context) { - WonderData wonderData = wondersLogic.getData(widget.type); - WonderIllustrationConfig bgConfig = WonderIllustrationConfig.bg( - enableAnims: false, - enableHero: false, - ); - WonderIllustrationConfig fgConfig = WonderIllustrationConfig( - enableAnims: false, - enableHero: false, - enableBg: false, - ); - Color fgColor = wonderData.type.bgColor; //.withOpacity(.5); - - _illustration = RepaintBoundary( - key: _containerKey, - child: ClipRect( - child: Stack( - children: [ - // Background - apply additional filter to make moon brighter - WonderIllustration( - widget.type, - config: bgConfig, - ), - - // Clouds - FractionallySizedBox( - widthFactor: 1, - heightFactor: .5, - child: AnimatedClouds( - wonderType: wonderData.type, - opacity: 1, - enableAnimations: false, - ), - ), - - // Wonder illustration - WonderIllustration( - widget.type, - config: fgConfig, - ), - - // Foreground gradient - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - fgColor.withOpacity(0), - fgColor.withOpacity(fgColor.opacity * .75), - ], - stops: const [0, 1], - ), - ), - ), - - // Title text - if (_showTitleText) - BottomCenter( - child: Transform.translate( - offset: Offset(0.0, -$styles.insets.xl * 2), - child: WonderTitleText(wonderData, enableShadows: true), - ), - ), - ], - ), - ), - ); - - return Stack(children: [ - Container( - decoration: BoxDecoration(backgroundBlendMode: BlendMode.color, color: Colors.blue), - child: _illustration ?? Container(), - ), - TopCenter( - child: SafeArea( - child: Padding( - padding: EdgeInsets.all($styles.insets.md), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), - child: BackBtn.close( - bgColor: $styles.colors.offWhite, - iconColor: $styles.colors.black, - ), - ), - Expanded(child: Container()), - Padding( - padding: const EdgeInsets.only(top: 10.0, bottom: 10.0, right: 16.0), - child: CircleIconBtn( - icon: PlatformInfo.isIOS ? AppIcons.share_ios : AppIcons.share_android, - bgColor: $styles.colors.offWhite, - color: $styles.colors.black, - onPressed: () => _handleSharePhoto(context, wonderData.title), - semanticLabel: $strings.wallpaperSemanticSharePhoto, - size: 44, - ), - ), - CircleIconBtn( - icon: AppIcons.download, - onPressed: () => _handleTakePhoto(context, wonderData.title), - semanticLabel: $strings.wallpaperSemanticTakePhoto, - size: 64, - ), - ], - ), - ), - ), - ), - BottomCenter( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SimpleCheckbox( - active: _showTitleText, label: $strings.wallpaperCheckboxShowTitle, onToggled: _handleTextToggle), - Gap($styles.insets.xl), - ], - ), - ), - ]); - } -} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 25ce8448..884b3cc9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,6 @@ import Foundation import desktop_window import package_info_plus import path_provider_foundation -import share_plus import shared_preferences_foundation import url_launcher_macos @@ -16,7 +15,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DesktopWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWindowPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index a8699082..15752177 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb - url: "https://pub.dev" - source: hosted - version: "0.3.3+5" crypto: dependency: transitive description: @@ -480,14 +472,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - mime: - dependency: transitive - description: - name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e - url: "https://pub.dev" - source: hosted - version: "1.0.4" nested: dependency: transitive description: @@ -672,22 +656,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - share_plus: - dependency: "direct main" - description: - name: share_plus - sha256: f86c5acc512b20e074137075824fc29e29b2cf395dcbfcc371e96e3e6290cce1 - url: "https://pub.dev" - source: hosted - version: "8.0.0" - share_plus_platform_interface: - dependency: transitive - description: - name: share_plus_platform_interface - sha256: "357412af4178d8e11d14f41723f80f12caea54cf0d5cd29af9dcdab85d58aea7" - url: "https://pub.dev" - source: hosted - version: "3.3.0" shared_preferences: dependency: "direct main" description: @@ -893,14 +861,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.8" - uuid: - dependency: transitive - description: - name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" - url: "https://pub.dev" - source: hosted - version: "3.0.7" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6b465de8..3ca6f44e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,7 +43,6 @@ dependencies: pointer_interceptor: ^0.9.3+6 provider: ^6.0.5 rnd: ^0.2.0 - share_plus: ^8.0.0 shared_preferences: ^2.0.17 sized_context: ^1.0.0+1 smooth_page_indicator: ^1.0.1 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index dcda11a9..33bc361e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,14 +7,11 @@ #include "generated_plugin_registrant.h" #include -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { DesktopWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopWindowPlugin")); - SharePlusWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ad701266..d3bb5785 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,7 +4,6 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_window - share_plus url_launcher_windows ) From 5b37a65f14ca509046fc97833dd2959ea8bfdd46 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 19 Oct 2023 16:26:41 -0600 Subject: [PATCH 02/36] Move flutter_native_splash to dependencies from dev_dependencies since it is used in main() to manage splash screen visibility. --- pubspec.lock | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 15752177..7a157e3e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -204,7 +204,7 @@ packages: source: sdk version: "0.0.0" flutter_native_splash: - dependency: "direct dev" + dependency: "direct main" description: name: flutter_native_splash sha256: "91004565166dbbc7a85e7e99b84124a287839830ca957cfe45004793fe6fe69f" diff --git a/pubspec.yaml b/pubspec.yaml index 3ca6f44e..ecafb89a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: flutter_animate: ^1.0.0 flutter_circular_text: ^0.3.1 flutter_displaymode: ^0.6.0 + flutter_native_splash: ^2.3.3 flutter_staggered_grid_view: ^0.7.0 flutter_svg: ^2.0.1 gap: ^3.0.1 @@ -54,7 +55,6 @@ dependencies: dev_dependencies: icons_launcher: ^2.1.3 flutter_lints: ^2.0.2 - flutter_native_splash: ^2.3.3 dependency_validator: ^3.2.2 icons_launcher: From 7f786610a7c10b88344343a058a0ab94f2157064 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 3 Nov 2023 14:29:46 -0600 Subject: [PATCH 03/36] Remove duplicated comment --- lib/logic/app_logic.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/logic/app_logic.dart b/lib/logic/app_logic.dart index f6b87a31..c2bf1d66 100644 --- a/lib/logic/app_logic.dart +++ b/lib/logic/app_logic.dart @@ -92,9 +92,6 @@ class AppLogic { bool shouldUseNavRail() => _appSize.width > _appSize.height && _appSize.height > 250; - /// Enable landscape, portrait or both. Views can call this method to override the default settings. - /// For example, the [FullscreenVideoViewer] always wants to enable both landscape and portrait. - /// If a view overrides this, it is responsible for setting it back to [supportedOrientations] when disposed. void _updateSystemOrientation() { final axisList = _supportedOrientationsOverride ?? supportedOrientations; //debugPrint('updateDeviceOrientation, supportedAxis: $axisList'); From 5d7f14fd17038c8bdd3a75ce8cf3e6200728c54d Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 3 Nov 2023 14:30:05 -0600 Subject: [PATCH 04/36] Fix centering issue in photo gallery with hidden collectibles --- lib/ui/screens/photo_gallery/photo_gallery.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/screens/photo_gallery/photo_gallery.dart b/lib/ui/screens/photo_gallery/photo_gallery.dart index f54f503c..1e31892f 100644 --- a/lib/ui/screens/photo_gallery/photo_gallery.dart +++ b/lib/ui/screens/photo_gallery/photo_gallery.dart @@ -213,7 +213,7 @@ class _PhotoGalleryState extends State { _handleImageTapped(index); }, child: _checkCollectibleIndex(index) - ? HiddenCollectible(widget.wonderType, index: 1, size: 100) + ? Center(child: HiddenCollectible(widget.wonderType, index: 1, size: 100)) : ClipRRect( borderRadius: BorderRadius.circular(8), child: SizedBox( From 04be1daf423951e96e0dd97782718cc0bd7d9080 Mon Sep 17 00:00:00 2001 From: Shawn Date: Tue, 21 Nov 2023 12:23:42 -0700 Subject: [PATCH 05/36] Add basic web analytics to html build --- web/index.html | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/web/index.html b/web/index.html index d8f83033..e63f0f0a 100644 --- a/web/index.html +++ b/web/index.html @@ -1,17 +1,13 @@ - + + From 1c853b1c396329609cebf0c9ca1e61e0c985d2a3 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 29 Nov 2023 15:59:25 -0700 Subject: [PATCH 06/36] Unify shortcuts on desktop/web, remove arrow-press for focus traversal from desktop. Add focusNode support for btns --- lib/main.dart | 3 ++ lib/ui/common/app_shortcuts.dart | 47 +++++++++++++++++++++ lib/ui/common/controls/buttons.dart | 48 +++++++++++++++++----- lib/ui/common/controls/circle_buttons.dart | 5 ++- 4 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 lib/ui/common/app_shortcuts.dart diff --git a/lib/main.dart b/lib/main.dart index 3ba7fa3d..9fd75723 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; @@ -9,6 +10,7 @@ import 'package:wonders/logic/artifact_api_service.dart'; import 'package:wonders/logic/timeline_logic.dart'; import 'package:wonders/logic/unsplash_logic.dart'; import 'package:wonders/logic/wonders_logic.dart'; +import 'package:wonders/ui/common/app_shortcuts.dart'; void main() async { WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); @@ -36,6 +38,7 @@ class WondersApp extends StatelessWidget with GetItMixin { locale: locale == null ? null : Locale(locale), debugShowCheckedModeBanner: false, routerDelegate: appRouter.routerDelegate, + shortcuts: AppShortcuts.defaults, theme: ThemeData(fontFamily: $styles.text.body.fontFamily, useMaterial3: true), localizationsDelegates: const [ AppLocalizations.delegate, diff --git a/lib/ui/common/app_shortcuts.dart b/lib/ui/common/app_shortcuts.dart new file mode 100644 index 00000000..ff3d3186 --- /dev/null +++ b/lib/ui/common/app_shortcuts.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:wonders/common_libs.dart'; + +class AppShortcuts { + static final Map _defaultWebAndDesktopShortcuts = { + // Activation + if (kIsWeb) ...{ + // On the web, enter activates buttons, but not other controls. + SingleActivator(LogicalKeyboardKey.enter): ButtonActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): ButtonActivateIntent(), + } else ...{ + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), + }, + + // Dismissal + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + + // Keyboard traversal. + SingleActivator(LogicalKeyboardKey.tab): NextFocusIntent(), + SingleActivator(LogicalKeyboardKey.tab, shift: true): PreviousFocusIntent(), + + // Scrolling + SingleActivator(LogicalKeyboardKey.arrowUp): ScrollIntent(direction: AxisDirection.up), + SingleActivator(LogicalKeyboardKey.arrowDown): ScrollIntent(direction: AxisDirection.down), + SingleActivator(LogicalKeyboardKey.arrowLeft): ScrollIntent(direction: AxisDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): ScrollIntent(direction: AxisDirection.right), + SingleActivator(LogicalKeyboardKey.pageUp): + ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + SingleActivator(LogicalKeyboardKey.pageDown): + ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), + }; + + static Map? get defaults { + switch (defaultTargetPlatform) { + // fall back to default shortcuts for ios and android + case TargetPlatform.iOS: + case TargetPlatform.android: + return null; + // unify shortcuts for desktop/web + default: + return _defaultWebAndDesktopShortcuts; + } + } +} diff --git a/lib/ui/common/controls/buttons.dart b/lib/ui/common/controls/buttons.dart index f90c7fc6..7001048e 100644 --- a/lib/ui/common/controls/buttons.dart +++ b/lib/ui/common/controls/buttons.dart @@ -22,6 +22,8 @@ class AppBtn extends StatelessWidget { this.minimumSize, this.bgColor, this.border, + this.focusNode, + this.onFocusChanged, }) : _builder = null, super(key: key); @@ -36,6 +38,8 @@ class AppBtn extends StatelessWidget { this.minimumSize, this.bgColor, this.border, + this.focusNode, + this.onFocusChanged, String? semanticLabel, String? text, AppIcons? icon, @@ -76,6 +80,8 @@ class AppBtn extends StatelessWidget { this.isSecondary = false, this.circular = false, this.minimumSize, + this.focusNode, + this.onFocusChanged, }) : expand = false, bgColor = Colors.transparent, border = null, @@ -86,6 +92,8 @@ class AppBtn extends StatelessWidget { final VoidCallback? onPressed; late final String semanticLabel; final bool enableFeedback; + final FocusNode? focusNode; + final void Function(bool hasFocus)? onFocusChanged; // content: late final Widget? child; @@ -129,15 +137,20 @@ class AppBtn extends StatelessWidget { ); Widget button = _CustomFocusBuilder( + focusNode: focusNode, + onFocusChanged: onFocusChanged, builder: (context, focus) => Stack( children: [ - TextButton( - onPressed: onPressed, - style: style, - focusNode: focus, - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith(color: textColor), - child: content, + Opacity( + opacity: onPressed == null ? 0.5 : 1.0, + child: TextButton( + onPressed: onPressed, + style: style, + focusNode: focus, + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith(color: textColor), + child: content, + ), ), ), if (focus.hasFocus) @@ -154,7 +167,7 @@ class AppBtn extends StatelessWidget { ); // add press effect: - if (pressEffect) button = _ButtonPressEffect(button); + if (pressEffect && onPressed != null) button = _ButtonPressEffect(button); // add semantics? if (semanticLabel.isEmpty) return button; @@ -199,15 +212,28 @@ class _ButtonPressEffectState extends State<_ButtonPressEffect> { } class _CustomFocusBuilder extends StatefulWidget { - const _CustomFocusBuilder({Key? key, required this.builder}) : super(key: key); + const _CustomFocusBuilder({Key? key, required this.builder, this.focusNode, this.onFocusChanged}) : super(key: key); final Widget Function(BuildContext context, FocusNode focus) builder; - + final void Function(bool hasFocus)? onFocusChanged; + final FocusNode? focusNode; @override State<_CustomFocusBuilder> createState() => _CustomFocusBuilderState(); } class _CustomFocusBuilderState extends State<_CustomFocusBuilder> { - late final _focusNode = FocusNode()..addListener(() => setState(() {})); + late final FocusNode _focusNode; + + @override + void initState() { + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.addListener(_handleFocusChanged); + super.initState(); + } + + void _handleFocusChanged() { + widget.onFocusChanged?.call(_focusNode.hasFocus); + setState(() {}); + } @override Widget build(BuildContext context) { diff --git a/lib/ui/common/controls/circle_buttons.dart b/lib/ui/common/controls/circle_buttons.dart index 3bddf06d..342aa3df 100644 --- a/lib/ui/common/controls/circle_buttons.dart +++ b/lib/ui/common/controls/circle_buttons.dart @@ -14,7 +14,7 @@ class CircleBtn extends StatelessWidget { static double defaultSize = 48; - final VoidCallback onPressed; + final VoidCallback? onPressed; final Color? bgColor; final BorderSide? border; final Widget child; @@ -54,7 +54,7 @@ class CircleIconBtn extends StatelessWidget { static double defaultSize = 28; final AppIcons icon; - final VoidCallback onPressed; + final VoidCallback? onPressed; final BorderSide? border; final Color? bgColor; final Color? color; @@ -103,6 +103,7 @@ class BackBtn extends StatelessWidget { semanticLabel: $strings.circleButtonsSemanticClose, bgColor: bgColor, iconColor: iconColor); + @override Widget build(BuildContext context) { return CircleIconBtn( From 5b595c3f31485773ce0e1f472e4b0a123f431ce4 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 29 Nov 2023 16:00:27 -0700 Subject: [PATCH 07/36] Add fullscreen keyboard listener --- .../common/fullscreen_keyboard_listener.dart | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 lib/ui/common/fullscreen_keyboard_listener.dart diff --git a/lib/ui/common/fullscreen_keyboard_listener.dart b/lib/ui/common/fullscreen_keyboard_listener.dart new file mode 100644 index 00000000..e4401af7 --- /dev/null +++ b/lib/ui/common/fullscreen_keyboard_listener.dart @@ -0,0 +1,39 @@ +import 'package:wonders/common_libs.dart'; + +class FullScreenKeyboardListener extends StatefulWidget { + const FullScreenKeyboardListener({super.key, required this.child, this.onKeyDown, this.onKeyUp}); + final Widget child; + final bool Function(KeyDownEvent event)? onKeyDown; + final bool Function(KeyUpEvent event)? onKeyUp; + + @override + State createState() => _FullScreenKeyboardListenerState(); +} + +class _FullScreenKeyboardListenerState extends State { + @override + void initState() { + super.initState(); + ServicesBinding.instance.keyboard.addHandler(_handleKey); + } + + @override + void dispose() { + ServicesBinding.instance.keyboard.removeHandler(_handleKey); + super.dispose(); + } + + bool _handleKey(KeyEvent event) { + bool result = false; + if (event is KeyDownEvent && widget.onKeyDown != null) { + result = widget.onKeyDown!.call(event); + } + if (event is KeyUpEvent && widget.onKeyUp != null) { + result = widget.onKeyUp!.call(event); + } + return result; + } + + @override + Widget build(BuildContext context) => widget.child; +} From 2887de47043433899d0925035c89ce81759eedb4 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 29 Nov 2023 16:01:08 -0700 Subject: [PATCH 08/36] Add arrowkey support for navigation Add workaround for lack of cutout support on web --- .../screens/photo_gallery/photo_gallery.dart | 266 +++++++++++------- .../widgets/_animated_cutout_overlay.dart | 21 +- 2 files changed, 181 insertions(+), 106 deletions(-) diff --git a/lib/ui/screens/photo_gallery/photo_gallery.dart b/lib/ui/screens/photo_gallery/photo_gallery.dart index 1e31892f..b1f1d6c2 100644 --- a/lib/ui/screens/photo_gallery/photo_gallery.dart +++ b/lib/ui/screens/photo_gallery/photo_gallery.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:wonders/common_libs.dart'; import 'package:wonders/logic/data/unsplash_photo_data.dart'; import 'package:wonders/ui/common/controls/app_loading_indicator.dart'; import 'package:wonders/ui/common/controls/eight_way_swipe_detector.dart'; +import 'package:wonders/ui/common/fullscreen_keyboard_listener.dart'; import 'package:wonders/ui/common/hidden_collectible.dart'; import 'package:wonders/ui/common/modals/fullscreen_url_img_viewer.dart'; import 'package:wonders/ui/common/unsplash_photo.dart'; @@ -33,10 +35,16 @@ class _PhotoGalleryState extends State { final _photoIds = ValueNotifier>([]); int get _imgCount => pow(_gridSize, 2).round(); + late final List _focusNodes = List.generate(_imgCount, (index) => FocusNode()); + + //TODO: Remove this field (and associated workarounds) once web properly supports ClipPath (https://github.com/flutter/flutter/issues/124675) + final bool useClipPathWorkAroundForWeb = kIsWeb; + @override void initState() { super.initState(); _initPhotoIds(); + _focusNodes[_index].requestFocus(); } Future _initPhotoIds() async { @@ -55,6 +63,7 @@ class _PhotoGalleryState extends State { if (value < 0 || value >= _imgCount) return; _skipNextOffsetTween = skipAnimation; setState(() => _index = value); + _focusNodes[value].requestFocus(); } /// Determine the required offset to show the current selected index. @@ -89,6 +98,37 @@ class _PhotoGalleryState extends State { } } + bool _handleKeyDown(KeyDownEvent event) { + var newIndex = -1; + bool handled = false; + final key = event.logicalKey; + Map keyActions = { + LogicalKeyboardKey.arrowUp: -_gridSize, + LogicalKeyboardKey.arrowDown: _gridSize, + LogicalKeyboardKey.arrowRight: 1, + LogicalKeyboardKey.arrowLeft: -1, + }; + + int? action = keyActions[key]; + if (action != null) { + newIndex = _index + action; + handled = true; + bool isRightSide = _index % _gridSize == _gridSize - 1; + if (isRightSide && key == LogicalKeyboardKey.arrowRight) { + newIndex = -1; + } + bool isLeftSide = _index % _gridSize == 0; + if (isLeftSide && key == LogicalKeyboardKey.arrowLeft) newIndex = -1; + if (newIndex > _gridSize * _gridSize) { + newIndex = -1; + } + if (newIndex >= 0) { + _setIndex(newIndex); + } + } + return handled; + } + /// Converts a swipe direction into a new index void _handleSwipe(Offset dir) { // Calculate new index, y swipes move by an entire row, x swipes move one index at a time @@ -104,7 +144,8 @@ class _PhotoGalleryState extends State { _setIndex(newIndex); } - Future _handleImageTapped(int index) async { + Future _handleImageTapped(int index, bool isSelected) async { + if (_checkCollectibleIndex(index) && isSelected) return; if (_index == index) { final urls = _photoIds.value.map((e) { return UnsplashPhotoData.getSelfHostedUrl(e, UnsplashPhotoSize.xl); @@ -122,119 +163,150 @@ class _PhotoGalleryState extends State { } } + void _handleImageFocusChanged(int index, bool isFocused) { + if (isFocused) { + _setIndex(index); + } + } + bool _checkCollectibleIndex(int index) { return index == _getCollectibleIndex() && collectiblesLogic.isLost(widget.wonderType, 1); } @override Widget build(BuildContext context) { - return ValueListenableBuilder>( - valueListenable: _photoIds, - builder: (_, value, __) { - if (value.isEmpty) { - return Center(child: AppLoadingIndicator()); - } - Size imgSize = context.isLandscape - ? Size(context.widthPx * .5, context.heightPx * .66) - : Size(context.widthPx * .66, context.heightPx * .5); - imgSize = (widget.imageSize ?? imgSize) * _scale; - // Get transform offset for the current _index - final padding = $styles.insets.md; - var gridOffset = _calculateCurrentOffset(padding, imgSize); - gridOffset += Offset(0, -context.mq.padding.top / 2); - final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration; - final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5; - return _AnimatedCutoutOverlay( - animationKey: ValueKey(_index), - cutoutSize: imgSize, - swipeDir: _lastSwipeDir, - duration: cutoutTweenDuration, - opacity: _scale == 1 ? .7 : .5, - child: SafeArea( - bottom: false, - // Place content in overflow box, to allow it to flow outside the parent - child: OverflowBox( - maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1), - maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1), - alignment: Alignment.center, - // Detect swipes in order to change index - child: EightWaySwipeDetector( - onSwipe: _handleSwipe, - threshold: 30, - // A tween animation builder moves from image to image based on current offset - child: TweenAnimationBuilder( - tween: Tween(begin: gridOffset, end: gridOffset), - duration: offsetTweenDuration, - curve: Curves.easeOut, - builder: (_, value, child) => Transform.translate(offset: value, child: child), - child: GridView.count( - physics: NeverScrollableScrollPhysics(), - crossAxisCount: _gridSize, - childAspectRatio: imgSize.aspectRatio, - mainAxisSpacing: padding, - crossAxisSpacing: padding, - children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)), + return FullScreenKeyboardListener( + onKeyDown: _handleKeyDown, + child: ValueListenableBuilder>( + valueListenable: _photoIds, + builder: (_, value, __) { + if (value.isEmpty) { + return Center(child: AppLoadingIndicator()); + } + Size imgSize = context.isLandscape + ? Size(context.widthPx * .5, context.heightPx * .66) + : Size(context.widthPx * .66, context.heightPx * .5); + imgSize = (widget.imageSize ?? imgSize) * _scale; + // Get transform offset for the current _index + final padding = $styles.insets.md; + var gridOffset = _calculateCurrentOffset(padding, imgSize); + gridOffset += Offset(0, -context.mq.padding.top / 2); + final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration; + final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5; + return _AnimatedCutoutOverlay( + animationKey: ValueKey(_index), + cutoutSize: imgSize, + swipeDir: _lastSwipeDir, + duration: cutoutTweenDuration, + opacity: _scale == 1 ? .7 : .5, + enabled: useClipPathWorkAroundForWeb == false, + child: SafeArea( + bottom: false, + // Place content in overflow box, to allow it to flow outside the parent + child: OverflowBox( + maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1), + maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1), + alignment: Alignment.center, + // Detect swipes in order to change index + child: EightWaySwipeDetector( + onSwipe: _handleSwipe, + threshold: 30, + // A tween animation builder moves from image to image based on current offset + child: TweenAnimationBuilder( + tween: Tween(begin: gridOffset, end: gridOffset), + duration: offsetTweenDuration, + curve: Curves.easeOut, + builder: (_, value, child) => Transform.translate(offset: value, child: child), + child: FocusTraversalGroup( + //policy: OrderedTraversalPolicy(), + child: GridView.count( + physics: NeverScrollableScrollPhysics(), + crossAxisCount: _gridSize, + childAspectRatio: imgSize.aspectRatio, + mainAxisSpacing: padding, + crossAxisSpacing: padding, + children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)), + ), + ), ), ), ), ), - ), - ); - }); + ); + }), + ); } Widget _buildImage(int index, Duration swipeDuration, Size imgSize) { /// Bind to collectibles.statesById because we might need to rebuild if a collectible is found. - return ValueListenableBuilder( - valueListenable: collectiblesLogic.statesById, - builder: (_, __, ___) { - bool selected = index == _index; - final imgUrl = _photoIds.value[index]; + return FocusTraversalOrder( + order: NumericFocusOrder(index.toDouble()), + child: ValueListenableBuilder( + valueListenable: collectiblesLogic.statesById, + builder: (_, __, ___) { + bool isSelected = index == _index; + final imgUrl = _photoIds.value[index]; + late String semanticLbl; + if (_checkCollectibleIndex(index)) { + semanticLbl = $strings.collectibleItemSemanticCollectible; + } else { + semanticLbl = !isSelected + ? $strings.photoGallerySemanticFocus(index + 1, _imgCount) + : $strings.photoGallerySemanticFullscreen(index + 1, _imgCount); + } - late String semanticLbl; - if (_checkCollectibleIndex(index)) { - semanticLbl = $strings.collectibleItemSemanticCollectible; - } else { - semanticLbl = !selected - ? $strings.photoGallerySemanticFocus(index + 1, _imgCount) - : $strings.photoGallerySemanticFullscreen(index + 1, _imgCount); - } - return MergeSemantics( - child: Semantics( - focused: selected, - image: !_checkCollectibleIndex(index), - liveRegion: selected, - onIncrease: () => _handleImageTapped(_index + 1), - onDecrease: () => _handleImageTapped(_index - 1), - child: AppBtn.basic( - semanticLabel: semanticLbl, - onPressed: () { - if (_checkCollectibleIndex(index) && selected) return; - _handleImageTapped(index); - }, - child: _checkCollectibleIndex(index) - ? Center(child: HiddenCollectible(widget.wonderType, index: 1, size: 100)) - : ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: imgSize.width, - height: imgSize.height, - child: TweenAnimationBuilder( - duration: $styles.times.med, - curve: Curves.easeOut, - tween: Tween(begin: 1, end: selected ? 1.15 : 1), - builder: (_, value, child) => Transform.scale(scale: value, child: child), - child: UnsplashPhoto( - imgUrl, - fit: BoxFit.cover, - size: UnsplashPhotoSize.large, - ).animate().fade(), + final photoWidget = TweenAnimationBuilder( + duration: $styles.times.med, + curve: Curves.easeOut, + tween: Tween(begin: 1, end: isSelected ? 1.15 : 1), + builder: (_, value, child) => Transform.scale(scale: value, child: child), + child: UnsplashPhoto( + imgUrl, + fit: BoxFit.cover, + size: UnsplashPhotoSize.large, + ).animate().fade(), + ); + + return MergeSemantics( + child: Semantics( + focused: isSelected, + image: !_checkCollectibleIndex(index), + liveRegion: isSelected, + onIncrease: () => _handleImageTapped(_index + 1, false), + onDecrease: () => _handleImageTapped(_index - 1, false), + child: AppBtn.basic( + semanticLabel: semanticLbl, + focusNode: _focusNodes[index], + onFocusChanged: (isFocused) => _handleImageFocusChanged(index, isFocused), + onPressed: () => _handleImageTapped(index, isSelected), + child: _checkCollectibleIndex(index) + ? Center(child: HiddenCollectible(widget.wonderType, index: 1, size: 100)) + : ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: imgSize.width, + height: imgSize.height, + child: (useClipPathWorkAroundForWeb == false) + ? photoWidget + : Stack( + children: [ + photoWidget, + // Because the web platform doesn't support clipPath, we use a workaround to highlight the selected image + Positioned.fill( + child: AnimatedOpacity( + duration: $styles.times.med, + opacity: isSelected ? 0 : .7, + child: ColoredBox(color: $styles.colors.black), + ), + ), + ], + ), ), ), - ), + ), ), - ), - ); - }); + ); + }), + ); } } diff --git a/lib/ui/screens/photo_gallery/widgets/_animated_cutout_overlay.dart b/lib/ui/screens/photo_gallery/widgets/_animated_cutout_overlay.dart index 5c3559d6..29aad469 100644 --- a/lib/ui/screens/photo_gallery/widgets/_animated_cutout_overlay.dart +++ b/lib/ui/screens/photo_gallery/widgets/_animated_cutout_overlay.dart @@ -4,23 +4,26 @@ part of '../photo_gallery.dart'; /// When animationKey changes, the box animates its size, shrinking then returning to its original size. /// Uses[_CutoutClipper] to create the cutout. class _AnimatedCutoutOverlay extends StatelessWidget { - const _AnimatedCutoutOverlay( - {Key? key, - required this.child, - required this.cutoutSize, - required this.animationKey, - this.duration, - required this.swipeDir, - required this.opacity}) - : super(key: key); + const _AnimatedCutoutOverlay({ + Key? key, + required this.child, + required this.cutoutSize, + required this.animationKey, + this.duration, + required this.swipeDir, + required this.opacity, + required this.enabled, + }) : super(key: key); final Widget child; final Size cutoutSize; final Key animationKey; final Offset swipeDir; final Duration? duration; final double opacity; + final bool enabled; @override Widget build(BuildContext context) { + if (!enabled) return child; return Stack( children: [ child, From 66ca833c58e935aa60afb28fe8bc12a4b441b246 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 29 Nov 2023 16:01:38 -0700 Subject: [PATCH 09/36] Add keyboard support to FullscreenUrlImgViewer --- .../modals/fullscreen_url_img_viewer.dart | 77 +++++++++++++++++-- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/lib/ui/common/modals/fullscreen_url_img_viewer.dart b/lib/ui/common/modals/fullscreen_url_img_viewer.dart index 29ee5351..2a42a67e 100644 --- a/lib/ui/common/modals/fullscreen_url_img_viewer.dart +++ b/lib/ui/common/modals/fullscreen_url_img_viewer.dart @@ -1,5 +1,7 @@ import 'package:wonders/common_libs.dart'; +import 'package:wonders/ui/common/app_icons.dart'; import 'package:wonders/ui/common/controls/app_header.dart'; +import 'package:wonders/ui/common/fullscreen_keyboard_listener.dart'; import 'package:wonders/ui/common/utils/app_haptics.dart'; class FullscreenUrlImgViewer extends StatefulWidget { @@ -15,7 +17,8 @@ class FullscreenUrlImgViewer extends StatefulWidget { class _FullscreenUrlImgViewerState extends State { final _isZoomed = ValueNotifier(false); - late final _controller = PageController(initialPage: widget.index); + late final _controller = PageController(initialPage: widget.index)..addListener(_handlePageChanged); + late final ValueNotifier _currentPage = ValueNotifier(widget.index); @override void dispose() { @@ -23,8 +26,31 @@ class _FullscreenUrlImgViewerState extends State { super.dispose(); } + bool _handleKeyDown(KeyDownEvent event) { + int dir = 0; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + dir = -1; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + dir = 1; + } + if (dir != 0) { + _animateToPage(_currentPage.value + dir); + return true; + } + return false; + } + + void _handlePageChanged() => _currentPage.value = _controller.page!.round(); + void _handleBackPressed() => Navigator.pop(context, _controller.page!.round()); + void _animateToPage(int page) { + if (page >= 0 || page < widget.urls.length) { + _controller.animateToPage(page, duration: 300.ms, curve: Curves.easeOut); + } + } + @override Widget build(BuildContext context) { Widget content = AnimatedBuilder( @@ -48,13 +74,48 @@ class _FullscreenUrlImgViewerState extends State { child: ExcludeSemantics(child: content), ); - return Container( - color: $styles.colors.black, - child: Stack( - children: [ - Positioned.fill(child: content), - AppHeader(onBack: _handleBackPressed, isTransparent: true), - ], + return FullScreenKeyboardListener( + onKeyDown: _handleKeyDown, + child: Container( + color: $styles.colors.black, + child: Stack( + children: [ + Positioned.fill(child: content), + AppHeader(onBack: _handleBackPressed, isTransparent: true), + // Show next/previous btns if there are more than one image + if (widget.urls.length > 1) ...{ + BottomCenter( + child: Padding( + padding: EdgeInsets.only(bottom: $styles.insets.md), + child: ValueListenableBuilder( + valueListenable: _currentPage, + builder: (_, int page, __) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleIconBtn( + icon: AppIcons.prev, + onPressed: page == 0 ? null : () => _animateToPage(page - 1), + semanticLabel: $strings.semanticsNext(''), + ), + Gap($styles.insets.xs), + Transform.scale( + scaleX: -1, + child: CircleIconBtn( + icon: AppIcons.prev, + onPressed: page == widget.urls.length - 1 ? null : () => _animateToPage(page + 1), + semanticLabel: $strings.semanticsNext(''), + ), + ) + ], + ); + }, + ), + ), + ) + } + ], + ), ), ); } From eb0534106fb36e003b8f4bed7c5514d5401c422b Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 29 Nov 2023 16:01:55 -0700 Subject: [PATCH 10/36] Update dart version --- pubspec.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 7a157e3e..9cc59056 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -61,10 +61,10 @@ packages: dependency: "direct main" description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" convert: dependency: transitive description: @@ -468,10 +468,10 @@ packages: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" nested: dependency: transitive description: @@ -492,10 +492,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "0351aaba3b267c4962ed73058a5f62a84de7e39670a20e2916a6baff2ffcfbe5" + sha256: "88bc797f44a94814f2213db1c9bd5badebafdfb8290ca9f78d4b9ee2a3db4d79" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.0.1" package_info_plus_platform_interface: dependency: transitive description: @@ -897,10 +897,10 @@ packages: dependency: transitive description: name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 url: "https://pub.dev" source: hosted - version: "0.1.4-beta" + version: "0.3.0" webview_flutter: dependency: "direct main" description: @@ -982,5 +982,5 @@ packages: source: hosted version: "2.0.2" sdks: - dart: ">=3.1.0 <4.0.0" + dart: ">=3.2.0 <4.0.0" flutter: ">=3.13.0" From 7feafe35b82c85eb5e42c019d963e817f8233061 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 30 Nov 2023 09:51:30 -0700 Subject: [PATCH 11/36] Fix issue with [Tab] key not working after arrow keys are used (focus gets lost?) --- lib/ui/common/modals/fullscreen_url_img_viewer.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/ui/common/modals/fullscreen_url_img_viewer.dart b/lib/ui/common/modals/fullscreen_url_img_viewer.dart index 2a42a67e..af7f0dd8 100644 --- a/lib/ui/common/modals/fullscreen_url_img_viewer.dart +++ b/lib/ui/common/modals/fullscreen_url_img_viewer.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:wonders/common_libs.dart'; import 'package:wonders/ui/common/app_icons.dart'; import 'package:wonders/ui/common/controls/app_header.dart'; @@ -35,7 +37,11 @@ class _FullscreenUrlImgViewerState extends State { dir = 1; } if (dir != 0) { + final focus = FocusManager.instance.primaryFocus; _animateToPage(_currentPage.value + dir); + scheduleMicrotask(() { + focus?.requestFocus(); + }); return true; } return false; From d2062cd2a4e5c8d3f2c2509273b65843058ece39 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 30 Nov 2023 10:08:15 -0700 Subject: [PATCH 12/36] Update min window size --- lib/styles/styles.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/styles/styles.dart b/lib/styles/styles.dart index 0a48907f..eddef519 100644 --- a/lib/styles/styles.dart +++ b/lib/styles/styles.dart @@ -160,7 +160,7 @@ class _Sizes { double get maxContentWidth1 => 800; double get maxContentWidth2 => 600; double get maxContentWidth3 => 500; - final Size minAppSize = Size(380, 250); + final Size minAppSize = Size(380, 650); } @immutable From d856f91a931bc90eae2262dc1f1cb8feca099caa Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 30 Nov 2023 10:41:05 -0700 Subject: [PATCH 13/36] Add physical btn for carousel navigation, add keyboard support, add flipIcon property to CircleIconbtn --- lib/ui/common/controls/circle_buttons.dart | 7 ++- .../modals/fullscreen_url_img_viewer.dart | 12 ++--- lib/ui/common/previous_next_navigation.dart | 54 +++++++++++++++++++ lib/ui/screens/home/wonders_home_screen.dart | 40 ++++++++------ 4 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 lib/ui/common/previous_next_navigation.dart diff --git a/lib/ui/common/controls/circle_buttons.dart b/lib/ui/common/controls/circle_buttons.dart index 342aa3df..9c44a130 100644 --- a/lib/ui/common/controls/circle_buttons.dart +++ b/lib/ui/common/controls/circle_buttons.dart @@ -47,6 +47,7 @@ class CircleIconBtn extends StatelessWidget { this.color, this.size, this.iconSize, + this.flipIcon = false, required this.semanticLabel, }) : super(key: key); @@ -61,6 +62,7 @@ class CircleIconBtn extends StatelessWidget { final String semanticLabel; final double? size; final double? iconSize; + final bool flipIcon; @override Widget build(BuildContext context) { @@ -72,7 +74,10 @@ class CircleIconBtn extends StatelessWidget { size: size, bgColor: bgColor ?? defaultColor, semanticLabel: semanticLabel, - child: AppIcon(icon, size: iconSize ?? defaultSize, color: iconColor), + child: Transform.scale( + scaleX: flipIcon ? -1 : 1, + child: AppIcon(icon, size: iconSize ?? defaultSize, color: iconColor), + ), ); } diff --git a/lib/ui/common/modals/fullscreen_url_img_viewer.dart b/lib/ui/common/modals/fullscreen_url_img_viewer.dart index af7f0dd8..3f44f281 100644 --- a/lib/ui/common/modals/fullscreen_url_img_viewer.dart +++ b/lib/ui/common/modals/fullscreen_url_img_viewer.dart @@ -105,13 +105,11 @@ class _FullscreenUrlImgViewerState extends State { semanticLabel: $strings.semanticsNext(''), ), Gap($styles.insets.xs), - Transform.scale( - scaleX: -1, - child: CircleIconBtn( - icon: AppIcons.prev, - onPressed: page == widget.urls.length - 1 ? null : () => _animateToPage(page + 1), - semanticLabel: $strings.semanticsNext(''), - ), + CircleIconBtn( + icon: AppIcons.prev, + flipIcon: true, + onPressed: page == widget.urls.length - 1 ? null : () => _animateToPage(page + 1), + semanticLabel: $strings.semanticsNext(''), ) ], ); diff --git a/lib/ui/common/previous_next_navigation.dart b/lib/ui/common/previous_next_navigation.dart new file mode 100644 index 00000000..b9ec8320 --- /dev/null +++ b/lib/ui/common/previous_next_navigation.dart @@ -0,0 +1,54 @@ +import 'package:wonders/common_libs.dart'; +import 'package:wonders/logic/common/platform_info.dart'; +import 'package:wonders/ui/common/app_icons.dart'; +import 'package:wonders/ui/common/fullscreen_keyboard_listener.dart'; + +class PreviousNextNavigation extends StatelessWidget { + const PreviousNextNavigation({super.key, this.onPreviousPressed, this.onNextPressed, required this.child}); + final VoidCallback? onPreviousPressed; + final VoidCallback? onNextPressed; + final Widget child; + + bool _handleKeyDown(KeyDownEvent event) { + if (event.logicalKey == LogicalKeyboardKey.arrowLeft && onPreviousPressed != null) { + onPreviousPressed?.call(); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNextPressed != null) { + onNextPressed?.call(); + return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + Widget buildBtn(String semanticLabel, VoidCallback? onPressed, Alignment align, {bool isNext = false}) { + if (PlatformInfo.isMobile) return child; + return FullScreenKeyboardListener( + onKeyDown: _handleKeyDown, + child: Align( + alignment: align, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: $styles.insets.md), + child: CircleIconBtn( + icon: AppIcons.prev, + onPressed: onPressed, + semanticLabel: semanticLabel, + flipIcon: isNext, + ), + ), + ), + ); + } + + return Stack( + children: [ + child, + //TODO-LOC: Add localization + buildBtn('previous', onPreviousPressed, Alignment.centerLeft), + buildBtn('next', onNextPressed, Alignment.centerRight, isNext: true), + ], + ); + } +} diff --git a/lib/ui/screens/home/wonders_home_screen.dart b/lib/ui/screens/home/wonders_home_screen.dart index 1b82d36b..518b54ff 100644 --- a/lib/ui/screens/home/wonders_home_screen.dart +++ b/lib/ui/screens/home/wonders_home_screen.dart @@ -4,6 +4,7 @@ import 'package:wonders/ui/common/app_icons.dart'; import 'package:wonders/ui/common/controls/app_header.dart'; import 'package:wonders/ui/common/controls/app_page_indicator.dart'; import 'package:wonders/ui/common/gradient_container.dart'; +import 'package:wonders/ui/common/previous_next_navigation.dart'; import 'package:wonders/ui/common/themed_text.dart'; import 'package:wonders/ui/common/utils/app_haptics.dart'; import 'package:wonders/ui/screens/home_menu/home_menu.dart'; @@ -87,11 +88,16 @@ class _HomeScreenState extends State with SingleTickerProviderStateM void _handlePageIndicatorDotPressed(int index) => _setPageIndex(index); - void _setPageIndex(int index) { + void _setPageIndex(int index, {bool animate = false}) { if (index == _wonderIndex) return; // To support infinite scrolling, we can't jump directly to the pressed index. Instead, make it relative to our current position. final pos = ((_pageController.page ?? 0) / _numWonders).floor() * _numWonders; - _pageController.jumpToPage(pos + index); + final newIndex = pos + index; + if (animate == true) { + _pageController.animateToPage(newIndex, duration: $styles.times.med, curve: Curves.easeOutCubic); + } else { + _pageController.jumpToPage(newIndex); + } } void _showDetailsPage() async { @@ -125,24 +131,24 @@ class _HomeScreenState extends State with SingleTickerProviderStateM return _swipeController.wrapGestureDetector(Container( color: $styles.colors.black, - child: Stack( - children: [ - Stack( - children: [ - /// Background - ..._buildBgAndClouds(), + child: PreviousNextNavigation( + onPreviousPressed: () => _setPageIndex(_wonderIndex - 1, animate: true), + onNextPressed: () => _setPageIndex(_wonderIndex + 1, animate: true), + child: Stack( + children: [ + /// Background + ..._buildBgAndClouds(), - /// Wonders Illustrations (main content) - _buildMgPageView(), + /// Wonders Illustrations (main content) + _buildMgPageView(), - /// Foreground illustrations and gradients - _buildFgAndGradients(), + /// Foreground illustrations and gradients + _buildFgAndGradients(), - /// Controls that float on top of the various illustrations - _buildFloatingUi(), - ], - ).animate().fadeIn(), - ], + /// Controls that float on top of the various illustrations + _buildFloatingUi(), + ], + ).animate().fadeIn(), ), )); } From 07bd69ac771006670a8311bc568e0fe091f07078 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 30 Nov 2023 13:11:54 -0700 Subject: [PATCH 14/36] Tweak styling on prevNextNav widget --- lib/ui/common/previous_next_navigation.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/common/previous_next_navigation.dart b/lib/ui/common/previous_next_navigation.dart index b9ec8320..1b7a79c9 100644 --- a/lib/ui/common/previous_next_navigation.dart +++ b/lib/ui/common/previous_next_navigation.dart @@ -30,7 +30,7 @@ class PreviousNextNavigation extends StatelessWidget { child: Align( alignment: align, child: Padding( - padding: EdgeInsets.symmetric(horizontal: $styles.insets.md), + padding: EdgeInsets.symmetric(horizontal: $styles.insets.sm), child: CircleIconBtn( icon: AppIcons.prev, onPressed: onPressed, From de6c18a42d91f30cd5bb518329f6f953eedbcdd1 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 4 Dec 2023 11:28:19 -0700 Subject: [PATCH 15/36] Add print statement for web users directing them to file issues on github --- lib/logic/app_logic.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/logic/app_logic.dart b/lib/logic/app_logic.dart index c2bf1d66..794b4936 100644 --- a/lib/logic/app_logic.dart +++ b/lib/logic/app_logic.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:ui'; import 'package:desktop_window/desktop_window.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:wonders/common_libs.dart'; import 'package:wonders/logic/common/platform_info.dart'; @@ -38,6 +39,13 @@ class AppLogic { await DesktopWindow.setMinWindowSize($styles.sizes.minAppSize); } + if (kIsWeb) { + // SB: This is intentionally not a debugPrint, as it's a message for users who open the console on web. + print( + 'Thanks for checking out Wonderous on the web!\nIf you encounter any issues please report them at https://github.com/gskinnerTeam/flutter-wonderous-app/issues.', + ); + } + // Load any bitmaps the views might need await AppBitmaps.init(); From a056373207ce5a71df147a729fd78ec19f5e80a1 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 4 Dec 2023 11:29:59 -0700 Subject: [PATCH 16/36] Cleanup naming --- lib/ui/screens/photo_gallery/photo_gallery.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/screens/photo_gallery/photo_gallery.dart b/lib/ui/screens/photo_gallery/photo_gallery.dart index b1f1d6c2..644649bd 100644 --- a/lib/ui/screens/photo_gallery/photo_gallery.dart +++ b/lib/ui/screens/photo_gallery/photo_gallery.dart @@ -175,7 +175,7 @@ class _PhotoGalleryState extends State { @override Widget build(BuildContext context) { - return FullScreenKeyboardListener( + return FullscreenKeyboardListener( onKeyDown: _handleKeyDown, child: ValueListenableBuilder>( valueListenable: _photoIds, From 9e4d457339db1ba2001080ca97ed4915096070af Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 4 Dec 2023 11:30:11 -0700 Subject: [PATCH 17/36] Add prev/next navigation to intro screen --- lib/ui/common/previous_next_navigation.dart | 68 ++++++++++------- lib/ui/screens/intro/intro_screen.dart | 81 ++++++++------------- 2 files changed, 75 insertions(+), 74 deletions(-) diff --git a/lib/ui/common/previous_next_navigation.dart b/lib/ui/common/previous_next_navigation.dart index 1b7a79c9..663f9f51 100644 --- a/lib/ui/common/previous_next_navigation.dart +++ b/lib/ui/common/previous_next_navigation.dart @@ -4,10 +4,20 @@ import 'package:wonders/ui/common/app_icons.dart'; import 'package:wonders/ui/common/fullscreen_keyboard_listener.dart'; class PreviousNextNavigation extends StatelessWidget { - const PreviousNextNavigation({super.key, this.onPreviousPressed, this.onNextPressed, required this.child}); + const PreviousNextNavigation( + {super.key, + required this.onPreviousPressed, + required this.onNextPressed, + required this.child, + this.maxWidth = 1000, + this.nextBtnColor, + this.previousBtnColor}); final VoidCallback? onPreviousPressed; final VoidCallback? onNextPressed; + final Color? nextBtnColor; + final Color? previousBtnColor; final Widget child; + final double? maxWidth; bool _handleKeyDown(KeyDownEvent event) { if (event.logicalKey == LogicalKeyboardKey.arrowLeft && onPreviousPressed != null) { @@ -23,32 +33,40 @@ class PreviousNextNavigation extends StatelessWidget { @override Widget build(BuildContext context) { - Widget buildBtn(String semanticLabel, VoidCallback? onPressed, Alignment align, {bool isNext = false}) { - if (PlatformInfo.isMobile) return child; - return FullScreenKeyboardListener( - onKeyDown: _handleKeyDown, - child: Align( - alignment: align, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: $styles.insets.sm), - child: CircleIconBtn( - icon: AppIcons.prev, - onPressed: onPressed, - semanticLabel: semanticLabel, - flipIcon: isNext, + if (PlatformInfo.isMobile) return child; + return FullscreenKeyboardListener( + onKeyDown: _handleKeyDown, + child: Stack( + children: [ + child, + Center( + child: SizedBox( + width: maxWidth ?? double.infinity, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: $styles.insets.sm), + child: Row( + children: [ + CircleIconBtn( + icon: AppIcons.prev, + onPressed: onPreviousPressed, + semanticLabel: 'Previous', + bgColor: previousBtnColor, + ), + Spacer(), + CircleIconBtn( + icon: AppIcons.prev, + onPressed: onNextPressed, + semanticLabel: 'Next', + flipIcon: true, + bgColor: nextBtnColor, + ) + ], + ), + ), ), ), - ), - ); - } - - return Stack( - children: [ - child, - //TODO-LOC: Add localization - buildBtn('previous', onPreviousPressed, Alignment.centerLeft), - buildBtn('next', onNextPressed, Alignment.centerRight, isNext: true), - ], + ], + ), ); } } diff --git a/lib/ui/screens/intro/intro_screen.dart b/lib/ui/screens/intro/intro_screen.dart index 56e620ac..b374b028 100644 --- a/lib/ui/screens/intro/intro_screen.dart +++ b/lib/ui/screens/intro/intro_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/gestures.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:wonders/common_libs.dart'; - import 'package:wonders/ui/common/app_icons.dart'; +import 'package:wonders/logic/common/platform_info.dart'; +import 'package:wonders/ui/common/app_icons.dart'; import 'package:wonders/ui/common/controls/app_page_indicator.dart'; import 'package:wonders/ui/common/gradient_container.dart'; +import 'package:wonders/ui/common/previous_next_navigation.dart'; import 'package:wonders/ui/common/static_text_scale.dart'; import 'package:wonders/ui/common/themed_text.dart'; import 'package:wonders/ui/common/utils/app_haptics.dart'; @@ -24,13 +26,14 @@ class _IntroScreenState extends State { static List<_PageData> pageData = []; late final PageController _pageController = PageController()..addListener(_handlePageChanged); - final ValueNotifier _currentPage = ValueNotifier(0); + late final ValueNotifier _currentPage = ValueNotifier(0)..addListener(() => setState(() {})); bool get _isOnLastPage => _currentPage.value.round() == pageData.length - 1; bool get _isOnFirstPage => _currentPage.value.round() == 0; @override void dispose() { _pageController.dispose(); + _currentPage.dispose(); super.dispose(); } @@ -53,15 +56,13 @@ class _IntroScreenState extends State { void _handleNavTextSemanticTap() => _incrementPage(1); - void _incrementPage(int dir){ + void _incrementPage(int dir) { final int current = _pageController.page!.round(); if (_isOnLastPage && dir > 0) return; if (_isOnFirstPage && dir < 0) return; _pageController.animateToPage(current + dir, duration: 250.ms, curve: Curves.easeIn); } - void _handleScrollWheel(double delta) => _incrementPage(delta >0? 1 : -1); - @override Widget build(BuildContext context) { // Set the page data, as strings may have changed based on locale @@ -78,20 +79,25 @@ class _IntroScreenState extends State { final List pages = pageData.map((e) => _Page(data: e)).toList(); /// Return resulting widget tree - return Listener( - onPointerSignal: (signal){ - if(signal is PointerScrollEvent){ - _handleScrollWheel(signal.scrollDelta.dy); - } - }, - child: DefaultTextColor( - color: $styles.colors.offWhite, - child: Container( - color: $styles.colors.black, - child: SafeArea( - child: Animate( - delay: 500.ms, - effects: const [FadeEffect()], + return DefaultTextColor( + color: $styles.colors.offWhite, + child: ColoredBox( + color: $styles.colors.black, + child: SafeArea( + child: Animate( + delay: 500.ms, + effects: const [FadeEffect()], + child: PreviousNextNavigation( + maxWidth: 600, + nextBtnColor: _isOnLastPage ? $styles.colors.accent1 : null, + onPreviousPressed: _isOnFirstPage ? null : () => _incrementPage(-1), + onNextPressed: () { + if (_isOnLastPage) { + _handleIntroCompletePressed(); + } else { + _incrementPage(1); + } + }, child: Stack( children: [ // page view with title & description: @@ -159,20 +165,15 @@ class _IntroScreenState extends State { _buildHzGradientOverlay(left: true), _buildHzGradientOverlay(), - // finish button: - Positioned( - right: $styles.insets.lg, - bottom: $styles.insets.lg, - child: _buildFinishBtn(context), - ), - // nav help text: - BottomCenter( - child: Padding( - padding: EdgeInsets.only(bottom: $styles.insets.lg), - child: _buildNavText(context), + if (PlatformInfo.isMobile) ...[ + BottomCenter( + child: Padding( + padding: EdgeInsets.only(bottom: $styles.insets.lg), + child: _buildNavText(context), + ), ), - ), + ] ], ), ), @@ -203,24 +204,6 @@ class _IntroScreenState extends State { ); } - Widget _buildFinishBtn(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _currentPage, - builder: (_, pageIndex, __) { - return AnimatedOpacity( - opacity: pageIndex == pageData.length - 1 ? 1 : 0, - duration: $styles.times.fast, - child: CircleIconBtn( - icon: AppIcons.next_large, - bgColor: $styles.colors.accent1, - onPressed: _handleIntroCompletePressed, - semanticLabel: $strings.introSemanticEnterApp, - ), - ); - }, - ); - } - Widget _buildNavText(BuildContext context) { return ValueListenableBuilder( valueListenable: _currentPage, From c2d33ac9d3f2f90013a58349cf8bf10a79ac2af6 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 4 Dec 2023 11:31:22 -0700 Subject: [PATCH 18/36] Cleanup naming --- lib/ui/common/modals/fullscreen_url_img_viewer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/common/modals/fullscreen_url_img_viewer.dart b/lib/ui/common/modals/fullscreen_url_img_viewer.dart index 3f44f281..c8dbe71c 100644 --- a/lib/ui/common/modals/fullscreen_url_img_viewer.dart +++ b/lib/ui/common/modals/fullscreen_url_img_viewer.dart @@ -80,7 +80,7 @@ class _FullscreenUrlImgViewerState extends State { child: ExcludeSemantics(child: content), ); - return FullScreenKeyboardListener( + return FullscreenKeyboardListener( onKeyDown: _handleKeyDown, child: Container( color: $styles.colors.black, From d710a9718a20912ce4300451a680d325ee3b3459 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 4 Dec 2023 11:32:45 -0700 Subject: [PATCH 19/36] Add fullscreen keyboard list scroller component, incorporate into editorial_screen --- .../fullscreen_keyboard_list_scroller.dart | 75 ++++++++++++++++ .../common/fullscreen_keyboard_listener.dart | 12 ++- .../screens/editorial/editorial_screen.dart | 89 ++++++++++--------- 3 files changed, 129 insertions(+), 47 deletions(-) create mode 100644 lib/ui/common/fullscreen_keyboard_list_scroller.dart diff --git a/lib/ui/common/fullscreen_keyboard_list_scroller.dart b/lib/ui/common/fullscreen_keyboard_list_scroller.dart new file mode 100644 index 00000000..2ca190fa --- /dev/null +++ b/lib/ui/common/fullscreen_keyboard_list_scroller.dart @@ -0,0 +1,75 @@ +import 'package:wonders/common_libs.dart'; +import 'package:wonders/logic/common/throttler.dart'; +import 'package:wonders/ui/common/fullscreen_keyboard_listener.dart'; + +class FullscreenKeyboardListScroller extends StatelessWidget { + FullscreenKeyboardListScroller({super.key, required this.child, required this.scrollController}); + + static const int _scrollAmountOnPress = 75; + static const int _scrollAmountOnHold = 30; + static final Duration _keyPressAnimationDuration = $styles.times.fast * .5; + + final Widget child; + final ScrollController scrollController; + final Throttler _throttler = Throttler(32.milliseconds); + + double clampOffset(px) => px.clamp(0, scrollController.position.maxScrollExtent).toDouble(); + + void _handleKeyDown(int px) { + scrollController.animateTo( + clampOffset(scrollController.offset + px), + duration: _keyPressAnimationDuration, + curve: Curves.easeOut, + ); + } + + void _handleKeyRepeat(int px) { + final offset = clampOffset(scrollController.offset + px); + _throttler.call(() => scrollController.jumpTo(offset)); + } + + @override + Widget build(BuildContext context) { + return FullscreenKeyboardListener( + child: child, + onKeyRepeat: (event) { + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + _handleKeyRepeat(-_scrollAmountOnHold); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + _handleKeyRepeat(_scrollAmountOnHold); + return true; + } + return false; + }, + onKeyDown: (event) { + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + _handleKeyDown(-_scrollAmountOnPress); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + _handleKeyDown(_scrollAmountOnPress); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.pageUp) { + _handleKeyDown(-_getViewportSize(context)); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.pageDown) { + _handleKeyDown(_getViewportSize(context)); + return true; + } + return false; + }, + ); + } + + int _getViewportSize(BuildContext context) { + final rb = context.findRenderObject() as RenderBox?; + if (rb != null) { + return rb.size.height.round() - 100; + } + return 0; + } +} diff --git a/lib/ui/common/fullscreen_keyboard_listener.dart b/lib/ui/common/fullscreen_keyboard_listener.dart index e4401af7..55620781 100644 --- a/lib/ui/common/fullscreen_keyboard_listener.dart +++ b/lib/ui/common/fullscreen_keyboard_listener.dart @@ -1,16 +1,17 @@ import 'package:wonders/common_libs.dart'; -class FullScreenKeyboardListener extends StatefulWidget { - const FullScreenKeyboardListener({super.key, required this.child, this.onKeyDown, this.onKeyUp}); +class FullscreenKeyboardListener extends StatefulWidget { + const FullscreenKeyboardListener({super.key, required this.child, this.onKeyDown, this.onKeyUp, this.onKeyRepeat}); final Widget child; final bool Function(KeyDownEvent event)? onKeyDown; final bool Function(KeyUpEvent event)? onKeyUp; + final bool Function(KeyRepeatEvent event)? onKeyRepeat; @override - State createState() => _FullScreenKeyboardListenerState(); + State createState() => _FullscreenKeyboardListenerState(); } -class _FullScreenKeyboardListenerState extends State { +class _FullscreenKeyboardListenerState extends State { @override void initState() { super.initState(); @@ -31,6 +32,9 @@ class _FullScreenKeyboardListenerState extends State if (event is KeyUpEvent && widget.onKeyUp != null) { result = widget.onKeyUp!.call(event); } + if (event is KeyRepeatEvent && widget.onKeyRepeat != null) { + result = widget.onKeyRepeat!.call(event); + } return result; } diff --git a/lib/ui/screens/editorial/editorial_screen.dart b/lib/ui/screens/editorial/editorial_screen.dart index 4d8da6df..d59a721c 100644 --- a/lib/ui/screens/editorial/editorial_screen.dart +++ b/lib/ui/screens/editorial/editorial_screen.dart @@ -14,6 +14,7 @@ import 'package:wonders/ui/common/centered_box.dart'; import 'package:wonders/ui/common/compass_divider.dart'; import 'package:wonders/ui/common/controls/app_header.dart'; import 'package:wonders/ui/common/curved_clippers.dart'; +import 'package:wonders/ui/common/fullscreen_keyboard_list_scroller.dart'; import 'package:wonders/ui/common/google_maps_marker.dart'; import 'package:wonders/ui/common/gradient_container.dart'; import 'package:wonders/ui/common/hidden_collectible.dart'; @@ -111,54 +112,56 @@ class _WonderEditorialScreenState extends State { padding: widget.contentPadding, child: SizedBox( child: FocusTraversalGroup( - child: CustomScrollView( - primary: false, - controller: _scroller, - scrollBehavior: ScrollConfiguration.of(context).copyWith(), - key: PageStorageKey('editorial'), - slivers: [ - /// Invisible padding at the top of the list, so the illustration shows through the btm - SliverToBoxAdapter( - child: SizedBox(height: illustrationHeight), - ), - - /// Text content, animates itself to hide behind the app bar as it scrolls up - SliverToBoxAdapter( - child: ValueListenableBuilder( - valueListenable: _scrollPos, - builder: (_, value, child) { - double offsetAmt = max(0, value * .3); - double opacity = (1 - offsetAmt / 150).clamp(0, 1); - return Transform.translate( - offset: Offset(0, offsetAmt), - child: Opacity(opacity: opacity, child: child), - ); - }, - child: _TitleText(widget.data, scroller: _scroller), + child: FullscreenKeyboardListScroller( + scrollController: _scroller, + child: CustomScrollView( + controller: _scroller, + scrollBehavior: ScrollConfiguration.of(context).copyWith(), + key: PageStorageKey('editorial'), + slivers: [ + /// Invisible padding at the top of the list, so the illustration shows through the btm + SliverToBoxAdapter( + child: SizedBox(height: illustrationHeight), ), - ), - /// Collapsing App bar, pins to the top of the list - SliverAppBar( - pinned: true, - collapsedHeight: minAppBarHeight, - toolbarHeight: minAppBarHeight, - expandedHeight: maxAppBarHeight, - backgroundColor: Colors.transparent, - elevation: 0, - leading: SizedBox.shrink(), - flexibleSpace: SizedBox.expand( - child: _AppBar( - widget.data.type, - scrollPos: _scrollPos, - sectionIndex: _sectionIndex, + /// Text content, animates itself to hide behind the app bar as it scrolls up + SliverToBoxAdapter( + child: ValueListenableBuilder( + valueListenable: _scrollPos, + builder: (_, value, child) { + double offsetAmt = max(0, value * .3); + double opacity = (1 - offsetAmt / 150).clamp(0, 1); + return Transform.translate( + offset: Offset(0, offsetAmt), + child: Opacity(opacity: opacity, child: child), + ); + }, + child: _TitleText(widget.data, scroller: _scroller), ), ), - ), - /// Editorial content (text and images) - _ScrollingContent(widget.data, scrollPos: _scrollPos, sectionNotifier: _sectionIndex), - ], + /// Collapsing App bar, pins to the top of the list + SliverAppBar( + pinned: true, + collapsedHeight: minAppBarHeight, + toolbarHeight: minAppBarHeight, + expandedHeight: maxAppBarHeight, + backgroundColor: Colors.transparent, + elevation: 0, + leading: SizedBox.shrink(), + flexibleSpace: SizedBox.expand( + child: _AppBar( + widget.data.type, + scrollPos: _scrollPos, + sectionIndex: _sectionIndex, + ), + ), + ), + + /// Editorial content (text and images) + _ScrollingContent(widget.data, scrollPos: _scrollPos, sectionNotifier: _sectionIndex), + ], + ), ), ), ), From fc2321291235e8c0f0ab88bc3319f9c7cc8d284d Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 4 Dec 2023 12:39:55 -0700 Subject: [PATCH 20/36] Enable semantics on startup, closes issue #146 --- lib/logic/app_logic.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/logic/app_logic.dart b/lib/logic/app_logic.dart index 794b4936..8c5b3190 100644 --- a/lib/logic/app_logic.dart +++ b/lib/logic/app_logic.dart @@ -44,6 +44,8 @@ class AppLogic { print( 'Thanks for checking out Wonderous on the web!\nIf you encounter any issues please report them at https://github.com/gskinnerTeam/flutter-wonderous-app/issues.', ); + // Required on web to automatically enable accessibility features + WidgetsFlutterBinding.ensureInitialized().ensureSemantics(); } // Load any bitmaps the views might need From 1f27c303e9ac7b029f3c11664953677d904f6f94 Mon Sep 17 00:00:00 2001 From: Jared Bell Date: Mon, 4 Dec 2023 14:30:59 -0700 Subject: [PATCH 21/36] Graphic fix: Pyramids Replace foreground-back graphic with taller, un-cropped version. --- .../images/pyramids/2.0x/foreground-back.png | Bin 46787 -> 92929 bytes .../images/pyramids/3.0x/foreground-back.png | Bin 83512 -> 171585 bytes .../images/pyramids/4.0x/foreground-back.png | Bin 130156 -> 268183 bytes assets/images/pyramids/foreground-back.png | Bin 19702 -> 34173 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/images/pyramids/2.0x/foreground-back.png b/assets/images/pyramids/2.0x/foreground-back.png index b6003ba3c9705071b30a4b665b73643575edb0e6..41514b7ae96fefd18d897194738645e2a42261e6 100644 GIT binary patch literal 92929 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