From 17e590a8ab1b03619a4aed94420e4d8f8fb657dc Mon Sep 17 00:00:00 2001 From: gree-patapata Date: Thu, 21 Mar 2024 10:58:28 +0900 Subject: [PATCH] patapata initial commit --- .clang-format | 3 + .editorconfig | 11 + .github/ISSUE_TEMPLATE/1_bug_report.md | 43 + .github/ISSUE_TEMPLATE/2_feature_request.md | 24 + .github/ISSUE_TEMPLATE/3_other.md | 15 + .github/PULL_REQUEST_TEMPLATE.md | 17 + .github/workflows/check.yml | 58 + .github/workflows/static.yml | 32 + .gitignore | 14 + .swiftformat | 6 + CHANGELOG.md | 4 + CONTRIBUTING.md | 141 ++ LICENSE | 21 + README.md | 88 + analysis_options.yaml | 8 + assets/logo_pata2_horizontal.png | Bin 0 -> 9695 bytes header_template.txt | 4 + melos.yaml | 203 ++ melos_patapata.iml | 12 + packages/patapata_adjust/.gitignore | 30 + packages/patapata_adjust/.metadata | 33 + packages/patapata_adjust/CHANGELOG.md | 3 + packages/patapata_adjust/LICENSE | 21 + packages/patapata_adjust/README.md | 75 + .../patapata_adjust/analysis_options.yaml | 7 + packages/patapata_adjust/android/.gitignore | 9 + packages/patapata_adjust/android/build.gradle | 57 + .../android/proguard-rules.pro | 12 + .../patapata_adjust/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 6 + .../patapata_adjust/PatapataAdjustPlugin.kt | 34 + packages/patapata_adjust/dartdoc_options.yaml | 8 + packages/patapata_adjust/ios/.gitignore | 38 + packages/patapata_adjust/ios/Assets/.gitkeep | 0 .../ios/Classes/PatapataAdjustPlugin.h | 11 + .../ios/Classes/PatapataAdjustPlugin.m | 20 + .../Classes/SwiftPatapataAdjustPlugin.swift | 19 + .../ios/patapata_adjust.podspec | 23 + .../patapata_adjust/lib/patapata_adjust.dart | 164 ++ packages/patapata_adjust/pubspec.yaml | 33 + .../test/patapata_adjust_test.dart | 12 + .../.gitignore | 9 + .../.metadata | 10 + .../CHANGELOG.md | 3 + .../patapata_apple_push_notifications/LICENSE | 21 + .../README.md | 48 + .../analysis_options.yaml | 9 + .../dartdoc_options.yaml | 8 + .../example/.gitignore | 46 + .../example/.metadata | 10 + .../example/README.md | 16 + .../example/analysis_options.yaml | 29 + .../example/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 26 + .../example/ios/Flutter/Debug.xcconfig | 2 + .../example/ios/Flutter/Release.xcconfig | 2 + .../example/ios/Podfile | 41 + .../example/ios/Podfile.lock | 60 + .../ios/Runner.xcodeproj/project.pbxproj | 542 +++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 91 + .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../example/ios/Runner/AppDelegate.swift | 18 + .../AppIcon.appiconset/Contents.json | 122 + .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 564 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 1588 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 1025 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 1716 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 1920 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 1895 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 3831 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 1888 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 3294 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 3612 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + .../example/ios/Runner/Info.plist | 49 + .../ios/Runner/Runner-Bridging-Header.h | 1 + .../example/lib/main.dart | 38 + .../example/pubspec.yaml | 84 + .../ios/.gitignore | 38 + .../ios/Assets/.gitkeep | 0 .../PatapataApplePushNotificationsPlugin.h | 11 + .../PatapataApplePushNotificationsPlugin.m | 20 + ...PatapataApplePushNotificationsPlugin.swift | 175 ++ .../patapata_apple_push_notifications.podspec | 25 + .../patapata_apple_push_notifications.dart | 187 ++ ...elos_patapata_apple_push_notifications.iml | 29 + .../pubspec.yaml | 26 + ...atapata_apple_push_notifications_test.dart | 12 + packages/patapata_apps_flyer/.gitignore | 75 + packages/patapata_apps_flyer/.metadata | 10 + packages/patapata_apps_flyer/CHANGELOG.md | 3 + packages/patapata_apps_flyer/LICENSE | 21 + packages/patapata_apps_flyer/README.md | 70 + .../patapata_apps_flyer/analysis_options.yaml | 9 + .../patapata_apps_flyer/dartdoc_options.yaml | 8 + packages/patapata_apps_flyer/ios/.gitignore | 38 + .../patapata_apps_flyer/ios/Assets/.gitkeep | 0 .../ios/Classes/PatapataAppsFlyerPlugin.h | 11 + .../ios/Classes/PatapataAppsFlyerPlugin.m | 20 + .../SwiftPatapataAppsFlyerPlugin.swift | 12 + .../ios/patapata_apps_flyer.podspec | 23 + .../lib/patapata_apps_flyer.dart | 97 + packages/patapata_apps_flyer/pubspec.yaml | 29 + .../test/patapata_apps_flyer_test.dart | 12 + .../.github/assets/logo_pata2_horizontal.png | Bin 0 -> 9695 bytes packages/patapata_core/.gitignore | 79 + packages/patapata_core/.metadata | 10 + packages/patapata_core/.vscode/launch.json | 20 + packages/patapata_core/.vscode/settings.json | 3 + packages/patapata_core/CHANGELOG.md | 3 + packages/patapata_core/LICENSE | 21 + packages/patapata_core/README.md | 1006 ++++++++ packages/patapata_core/analysis_options.yaml | 7 + packages/patapata_core/android/.gitignore | 13 + packages/patapata_core/android/build.gradle | 76 + .../patapata_core/android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 5 + .../patapata_core/android/proguard-rules.pro | 32 + .../patapata_core/android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../dev/patapata/patapata_core/Error.kt | 22 + .../patapata_core/NativeLocalConfig.kt | 122 + .../patapata_core/PatapataCorePlugin.kt | 106 + .../patapata/patapata_core/PatapataPlugin.kt | 14 + packages/patapata_core/bin/bootstrap.dart | 999 ++++++++ packages/patapata_core/dartdoc_options.yaml | 15 + packages/patapata_core/ios/.gitignore | 38 + packages/patapata_core/ios/Assets/.gitkeep | 0 .../ios/Classes/NativeLocalConfig.swift | 128 + .../ios/Classes/PatapataCorePlugin.swift | 128 + .../ios/Classes/PatapataCorePluginBridge.h | 11 + .../ios/Classes/PatapataCorePluginBridge.m | 20 + .../ios/Classes/PatapataPlugin.swift | 16 + .../patapata_core/ios/patapata_core.podspec | 23 + .../lib/finder/local_config_finder.dart | 9 + packages/patapata_core/lib/patapata_core.dart | 31 + .../patapata_core/lib/patapata_core_libs.dart | 11 + .../patapata_core/lib/patapata_core_web.dart | 67 + .../patapata_core/lib/patapata_widgets.dart | 10 + packages/patapata_core/lib/src/analytics.dart | 1291 ++++++++++ packages/patapata_core/lib/src/app.dart | 924 +++++++ packages/patapata_core/lib/src/config.dart | 140 ++ .../patapata_core/lib/src/device_info.dart | 598 +++++ packages/patapata_core/lib/src/error.dart | 296 +++ packages/patapata_core/lib/src/exception.dart | 83 + packages/patapata_core/lib/src/fake_ffi.dart | 13 + packages/patapata_core/lib/src/i18n.dart | 410 ++++ .../patapata_core/lib/src/local_config.dart | 339 +++ packages/patapata_core/lib/src/log.dart | 988 ++++++++ .../patapata_core/lib/src/logic_state.dart | 439 ++++ .../lib/src/method_channel_test_mixin.dart | 28 + .../lib/src/native_ffi_finder.dart | 59 + .../lib/src/native_local_config.dart | 34 + .../lib/src/native_local_config_finder.dart | 212 ++ packages/patapata_core/lib/src/network.dart | 230 ++ .../patapata_core/lib/src/notifications.dart | 304 +++ .../patapata_core/lib/src/package_info.dart | 83 + .../patapata_core/lib/src/permissions.dart | 245 ++ packages/patapata_core/lib/src/plugin.dart | 257 ++ .../patapata_core/lib/src/provider_model.dart | 672 +++++ .../patapata_core/lib/src/remote_config.dart | 340 +++ .../lib/src/remote_messaging.dart | 337 +++ .../lib/src/sequential_work_queue.dart | 472 ++++ packages/patapata_core/lib/src/startup.dart | 395 +++ packages/patapata_core/lib/src/user.dart | 497 ++++ packages/patapata_core/lib/src/util.dart | 289 +++ .../lib/src/widgets/platform_dialog.dart | 183 ++ .../lib/src/widgets/screen_layout.dart | 562 +++++ .../lib/src/widgets/standard_app.dart | 528 ++++ .../lib/src/widgets/standard_app_mixin.dart | 63 + .../src/widgets/standard_cupertino_app.dart | 168 ++ .../src/widgets/standard_material_app.dart | 204 ++ .../lib/src/widgets/standard_page.dart | 1952 +++++++++++++++ .../lib/src/widgets/standard_page_widget.dart | 78 + .../lib/web/patapata_web_plugin.dart | 11 + .../lib/web/web_local_config.dart | 111 + .../lib/web/web_local_config_finder.dart | 106 + packages/patapata_core/macos/.gitignore | 38 + .../macos/Classes/NativeLocalConfig.swift | 120 + .../macos/Classes/PatapataCorePlugin.swift | 100 + .../macos/Classes/PatapataCorePluginBridge.h | 11 + .../macos/Classes/PatapataCorePluginBridge.m | 20 + .../macos/Classes/PatapataPlugin.swift | 16 + .../patapata_core/macos/patapata_core.podspec | 22 + packages/patapata_core/makefile | 5 + packages/patapata_core/pubspec.yaml | 71 + .../patapata_core/test/analytics_test.dart | 1933 +++++++++++++++ packages/patapata_core/test/app_test.dart | 837 +++++++ .../patapata_core/test/assets/images/logo.svg | 1 + .../patapata_core/test/device_info_test.dart | 540 ++++ packages/patapata_core/test/error_test.dart | 434 ++++ .../ScreenLayout_BreakPoint_CopyTest.png | Bin 0 -> 12641 bytes ..._ChangeBreakPoint_Size(1668.0, 2224.0).png | Bin 0 -> 32355 bytes ..._ChangeBreakPoint_Size(1668.0, 2388.0).png | Bin 0 -> 33776 bytes ..._ChangeBreakPoint_Size(1920.0, 1080.0).png | Bin 0 -> 23360 bytes ..._ChangeBreakPoint_Size(2048.0, 2732.0).png | Bin 0 -> 41195 bytes ...ut_ChangeBreakPoint_Size(375.0, 667.0).png | Bin 0 -> 7127 bytes ...ut_ChangeBreakPoint_Size(375.0, 812.0).png | Bin 0 -> 7721 bytes ...ut_ChangeBreakPoint_Size(414.0, 736.0).png | Bin 0 -> 8041 bytes ...ut_ChangeBreakPoint_Size(414.0, 896.0).png | Bin 0 -> 8673 bytes ...t_ChangeBreakPoint_Size(768.0, 1024.0).png | Bin 0 -> 15424 bytes ...eenLayout_Disable_Size(1668.0, 2224.0).png | Bin 0 -> 30507 bytes ...eenLayout_Disable_Size(1668.0, 2388.0).png | Bin 0 -> 31921 bytes ...eenLayout_Disable_Size(1920.0, 1080.0).png | Bin 0 -> 21495 bytes ...eenLayout_Disable_Size(2048.0, 2732.0).png | Bin 0 -> 39342 bytes ...creenLayout_Disable_Size(375.0, 667.0).png | Bin 0 -> 12641 bytes ...creenLayout_Disable_Size(375.0, 812.0).png | Bin 0 -> 13223 bytes ...creenLayout_Disable_Size(414.0, 736.0).png | Bin 0 -> 13099 bytes ...creenLayout_Disable_Size(414.0, 896.0).png | Bin 0 -> 13839 bytes ...reenLayout_Disable_Size(768.0, 1024.0).png | Bin 0 -> 15985 bytes .../ScreenLayout_Size(1668.0, 2224.0).png | Bin 0 -> 32348 bytes .../ScreenLayout_Size(1668.0, 2388.0).png | Bin 0 -> 33768 bytes .../ScreenLayout_Size(1920.0, 1080.0).png | Bin 0 -> 23336 bytes .../ScreenLayout_Size(2048.0, 2732.0).png | Bin 0 -> 41199 bytes .../ScreenLayout_Size(375.0, 667.0).png | Bin 0 -> 12641 bytes .../ScreenLayout_Size(375.0, 812.0).png | Bin 0 -> 13223 bytes .../ScreenLayout_Size(414.0, 736.0).png | Bin 0 -> 14042 bytes .../ScreenLayout_Size(414.0, 896.0).png | Bin 0 -> 14782 bytes .../ScreenLayout_Size(667.0, 375.0).png | Bin 0 -> 14097 bytes .../ScreenLayout_Size(768.0, 1024.0).png | Bin 0 -> 17958 bytes ...t_Transform_Disable_Size(414.0, 736.0).png | Bin 0 -> 13113 bytes ...eenLayout_Transform_Size(414.0, 736.0).png | Bin 0 -> 13984 bytes packages/patapata_core/test/i18n_test.dart | 370 +++ .../test/local_config_plugin_test.dart | 52 + packages/patapata_core/test/log_test.dart | 1390 +++++++++++ .../patapata_core/test/logic_state_test.dart | 525 ++++ .../test/native_local_config_test.dart | 162 ++ packages/patapata_core/test/network_test.dart | 227 ++ .../test/notifications_test.dart | 156 ++ .../patapata_core/test/package_info_test.dart | 39 + .../patapata_core/test/pages/error_page.dart | 23 + .../patapata_core/test/pages/home_page.dart | 22 + .../test/pages/notification_page.dart | 21 + .../test/pages/startup_page.dart | 232 ++ .../permissions_for_android_test.dart | 115 + .../permission/permissions_for_ios_test.dart | 216 ++ .../permissions_for_macos_test.dart | 103 + .../test/platform_dialog_test.dart | 222 ++ packages/patapata_core/test/plugin_test.dart | 229 ++ .../test/provider_model_test.dart | 723 ++++++ .../test/proxy_local_config_test.dart | 196 ++ .../test/remote_config_test.dart | 334 +++ .../test/remote_messaging_test.dart | 317 +++ .../test/screen_layout_test.dart | 842 +++++++ .../test/sequential_work_queue_test.dart | 1358 +++++++++++ .../test/standard_app_logic_test.dart | 72 + .../test/standard_app_widget_tab_test.dart | 863 +++++++ .../test/standard_app_widget_test.dart | 2167 +++++++++++++++++ packages/patapata_core/test/startup_test.dart | 1233 ++++++++++ packages/patapata_core/test/user_test.dart | 314 +++ packages/patapata_core/test/util_test.dart | 458 ++++ .../test/utils/patapata_core_test_utils.dart | 268 ++ .../utils/standard_app_widget_test_data.dart | 670 +++++ packages/patapata_example_app/.gitignore | 44 + packages/patapata_example_app/.metadata | 45 + packages/patapata_example_app/README.md | 29 + .../analysis_options.yaml | 28 + .../patapata_example_app/android/.gitignore | 13 + .../android/app/build.gradle | 67 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 19 + .../patapata_example_app/MainActivity.kt | 13 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + .../patapata_example_app/android/build.gradle | 31 + .../android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + .../android/settings.gradle | 20 + packages/patapata_example_app/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 26 + .../ios/Flutter/Debug.xcconfig | 2 + .../ios/Flutter/Release.xcconfig | 2 + packages/patapata_example_app/ios/Podfile | 44 + .../patapata_example_app/ios/Podfile.lock | 53 + .../ios/Runner.xcodeproj/project.pbxproj | 617 +++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 98 + .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../ios/Runner/AppDelegate.swift | 18 + .../AppIcon.appiconset/Contents.json | 122 + .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + .../ios/Runner/Info.plist | 51 + .../ios/Runner/Runner-Bridging-Header.h | 1 + .../ios/RunnerTests/RunnerTests.swift | 17 + packages/patapata_example_app/l10n/ar.yaml | 102 + packages/patapata_example_app/l10n/en.yaml | 102 + packages/patapata_example_app/l10n/ja.yaml | 102 + .../patapata_example_app/lib/exceptions.dart | 61 + packages/patapata_example_app/lib/main.dart | 326 +++ .../patapata_example_app/lib/page_data.dart | 34 + .../lib/src/cupertino/pages/home_page.dart | 42 + .../lib/src/cupertino/pages/my_page.dart | 42 + .../lib/src/cupertino/pages/top_page.dart | 33 + .../lib/src/cupertino/widgets/app_tab.dart | 69 + .../lib/src/environment.dart | 41 + .../patapata_example_app/lib/src/errors.dart | 56 + .../lib/src/pages/agreement_page.dart | 43 + .../lib/src/pages/config_page.dart | 57 + .../pages/device_and_package_info_page.dart | 134 + .../lib/src/pages/error_page.dart | 131 + .../lib/src/pages/home_page.dart | 77 + .../lib/src/pages/my_page.dart | 46 + .../src/pages/screen_layout_example_page.dart | 143 ++ .../lib/src/pages/splash_page.dart | 19 + .../src/pages/standard_page_example_page.dart | 161 ++ .../lib/src/pages/top_page.dart | 71 + .../patapata_example_app/lib/src/startup.dart | 53 + .../lib/src/widgets/app_tab.dart | 66 + .../patapata_example_app/linux/.gitignore | 1 + .../patapata_example_app/linux/CMakeLists.txt | 139 ++ .../linux/flutter/CMakeLists.txt | 88 + .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 23 + packages/patapata_example_app/linux/main.cc | 11 + .../linux/my_application.cc | 109 + .../linux/my_application.h | 23 + .../patapata_example_app/macos/.gitignore | 7 + .../macos/Flutter/Flutter-Debug.xcconfig | 2 + .../macos/Flutter/Flutter-Release.xcconfig | 2 + .../Flutter/GeneratedPluginRegistrant.swift | 20 + packages/patapata_example_app/macos/Podfile | 43 + .../macos/Runner.xcodeproj/project.pbxproj | 695 ++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 98 + .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../macos/Runner/AppDelegate.swift | 14 + .../AppIcon.appiconset/Contents.json | 68 + .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 102994 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5680 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 520 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 14142 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1066 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 36406 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2218 bytes .../macos/Runner/Base.lproj/MainMenu.xib | 343 +++ .../macos/Runner/Configs/AppInfo.xcconfig | 14 + .../macos/Runner/Configs/Debug.xcconfig | 2 + .../macos/Runner/Configs/Release.xcconfig | 2 + .../macos/Runner/Configs/Warnings.xcconfig | 13 + .../macos/Runner/DebugProfile.entitlements | 12 + .../macos/Runner/Info.plist | 32 + .../macos/Runner/MainFlutterWindow.swift | 20 + .../macos/Runner/Release.entitlements | 8 + .../macos/RunnerTests/RunnerTests.swift | 17 + packages/patapata_example_app/pubspec.yaml | 93 + packages/patapata_example_app/web/favicon.png | Bin 0 -> 917 bytes .../web/icons/Icon-192.png | Bin 0 -> 5292 bytes .../web/icons/Icon-512.png | Bin 0 -> 8252 bytes .../web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes .../web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes packages/patapata_example_app/web/index.html | 59 + .../patapata_example_app/web/manifest.json | 35 + .../patapata_example_app/windows/.gitignore | 17 + .../windows/CMakeLists.txt | 102 + .../windows/flutter/CMakeLists.txt | 104 + .../flutter/generated_plugin_registrant.cc | 14 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 24 + .../windows/runner/CMakeLists.txt | 40 + .../windows/runner/Runner.rc | 121 + .../windows/runner/flutter_window.cpp | 71 + .../windows/runner/flutter_window.h | 40 + .../windows/runner/main.cpp | 43 + .../windows/runner/resource.h | 16 + .../windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes .../windows/runner/runner.exe.manifest | 20 + .../windows/runner/utils.cpp | 70 + .../windows/runner/utils.h | 26 + .../windows/runner/win32_window.cpp | 293 +++ .../windows/runner/win32_window.h | 109 + .../patapata_firebase_analytics/.gitignore | 75 + .../patapata_firebase_analytics/.metadata | 10 + .../patapata_firebase_analytics/CHANGELOG.md | 3 + packages/patapata_firebase_analytics/LICENSE | 21 + .../patapata_firebase_analytics/README.md | 63 + .../analysis_options.yaml | 9 + .../dartdoc_options.yaml | 8 + .../lib/patapata_firebase_analytics.dart | 91 + .../patapata_firebase_analytics/pubspec.yaml | 26 + .../patapata_firebase_analytics_test.dart | 12 + packages/patapata_firebase_auth/.gitignore | 79 + packages/patapata_firebase_auth/.metadata | 45 + packages/patapata_firebase_auth/CHANGELOG.md | 3 + packages/patapata_firebase_auth/LICENSE | 21 + packages/patapata_firebase_auth/README.md | 63 + .../analysis_options.yaml | 4 + .../lib/patapata_firebase_auth.dart | 70 + packages/patapata_firebase_auth/pubspec.yaml | 24 + .../test/patapata_firebase_auth_test.dart | 12 + packages/patapata_firebase_core/.gitignore | 75 + packages/patapata_firebase_core/.metadata | 10 + packages/patapata_firebase_core/CHANGELOG.md | 3 + packages/patapata_firebase_core/LICENSE | 21 + packages/patapata_firebase_core/README.md | 68 + .../analysis_options.yaml | 9 + .../dartdoc_options.yaml | 8 + .../lib/patapata_firebase_core.dart | 48 + packages/patapata_firebase_core/pubspec.yaml | 24 + .../test/patapata_firebase_core_test.dart | 12 + .../patapata_firebase_crashlytics/.gitignore | 75 + .../patapata_firebase_crashlytics/.metadata | 10 + .../CHANGELOG.md | 3 + .../patapata_firebase_crashlytics/LICENSE | 21 + .../patapata_firebase_crashlytics/README.md | 51 + .../analysis_options.yaml | 9 + .../dartdoc_options.yaml | 8 + .../lib/patapata_firebase_crashlytics.dart | 110 + .../pubspec.yaml | 27 + .../patapata_firebase_crashlytics_test.dart | 12 + .../.gitignore | 75 + .../patapata_firebase_dynamic_links/.metadata | 10 + .../CHANGELOG.md | 3 + .../patapata_firebase_dynamic_links/LICENSE | 21 + .../patapata_firebase_dynamic_links/README.md | 58 + .../analysis_options.yaml | 9 + .../dartdoc_options.yaml | 8 + .../lib/patapata_firebase_dynamic_links.dart | 180 ++ .../pubspec.yaml | 27 + .../patapata_firebase_dynamic_links_test.dart | 12 + .../patapata_firebase_messaging/.gitignore | 75 + .../patapata_firebase_messaging/.metadata | 10 + .../patapata_firebase_messaging/CHANGELOG.md | 3 + packages/patapata_firebase_messaging/LICENSE | 21 + .../patapata_firebase_messaging/README.md | 51 + .../analysis_options.yaml | 9 + .../android/.gitignore | 8 + .../android/build.gradle | 51 + .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../PatapataFirebaseMessagingPlugin.kt | 109 + .../dartdoc_options.yaml | 8 + .../lib/patapata_firebase_messaging.dart | 472 ++++ .../patapata_firebase_messaging/pubspec.yaml | 34 + .../patapata_firebase_messaging_test.dart | 12 + .../.gitignore | 75 + .../patapata_firebase_remote_config/.metadata | 10 + .../CHANGELOG.md | 3 + .../patapata_firebase_remote_config/LICENSE | 21 + .../patapata_firebase_remote_config/README.md | 51 + .../analysis_options.yaml | 9 + .../dartdoc_options.yaml | 8 + .../lib/patapata_firebase_remote_config.dart | 160 ++ .../pubspec.yaml | 26 + .../patapata_firebase_remote_config_test.dart | 12 + packages/patapata_karte_core/.gitignore | 9 + packages/patapata_karte_core/.metadata | 10 + packages/patapata_karte_core/CHANGELOG.md | 3 + packages/patapata_karte_core/LICENSE | 21 + packages/patapata_karte_core/README.md | 68 + .../patapata_karte_core/analysis_options.yaml | 9 + .../patapata_karte_core/android/.gitignore | 8 + .../patapata_karte_core/android/build.gradle | 54 + .../android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + .../android/settings.gradle | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../PatapataKarteCorePlugin.kt | 71 + .../patapata_karte_core/dartdoc_options.yaml | 8 + packages/patapata_karte_core/ios/.gitignore | 38 + .../patapata_karte_core/ios/Assets/.gitkeep | 0 .../ios/Classes/PatapataKarteCorePlugin.h | 11 + .../ios/Classes/PatapataKarteCorePlugin.m | 20 + .../SwiftPatapataKarteCorePlugin.swift | 22 + .../ios/patapata_karte_core.podspec | 31 + .../lib/patapata_karte_core.dart | 98 + .../melos_patapata_karte_core.iml | 29 + packages/patapata_karte_core/pubspec.yaml | 33 + .../test/patapata_karte_core_test.dart | 12 + packages/patapata_karte_variables/.gitignore | 75 + packages/patapata_karte_variables/.metadata | 10 + .../patapata_karte_variables/CHANGELOG.md | 3 + packages/patapata_karte_variables/LICENSE | 21 + packages/patapata_karte_variables/README.md | 55 + .../analysis_options.yaml | 9 + .../dartdoc_options.yaml | 8 + .../lib/patapata_karte_variables.dart | 216 ++ .../patapata_karte_variables/pubspec.yaml | 27 + .../test/patapata_karte_variables_test.dart | 12 + packages/patapata_riverpod/.gitignore | 75 + packages/patapata_riverpod/.metadata | 10 + packages/patapata_riverpod/CHANGELOG.md | 3 + packages/patapata_riverpod/LICENSE | 21 + packages/patapata_riverpod/README.md | 51 + .../patapata_riverpod/analysis_options.yaml | 11 + .../lib/patapata_riverpod.dart | 9 + .../patapata_riverpod/lib/src/providers.dart | 198 ++ .../lib/src/providers.g.dart | 1560 ++++++++++++ .../lib/src/riverpod_plugin.dart | 15 + packages/patapata_riverpod/pubspec.yaml | 28 + .../test/patapata_riverpod_test.dart | 659 +++++ packages/patapata_sentry/.gitignore | 75 + packages/patapata_sentry/.metadata | 10 + packages/patapata_sentry/CHANGELOG.md | 3 + packages/patapata_sentry/LICENSE | 21 + packages/patapata_sentry/README.md | 74 + .../patapata_sentry/analysis_options.yaml | 9 + packages/patapata_sentry/dartdoc_options.yaml | 8 + .../patapata_sentry/lib/patapata_sentry.dart | 266 ++ packages/patapata_sentry/pubspec.yaml | 25 + .../test/patapata_sentry_test.dart | 12 + pubspec.lock | 317 +++ pubspec.yaml | 6 + tools/dartdoc_all.sh | 23 + tools/pub_get_all.sh | 9 + tools/pub_upgrade_all.sh | 9 + tools/test_all.sh | 9 + 557 files changed, 52999 insertions(+) create mode 100644 .clang-format create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/1_bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/2_feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/3_other.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/static.yml create mode 100644 .gitignore create mode 100644 .swiftformat create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 assets/logo_pata2_horizontal.png create mode 100644 header_template.txt create mode 100644 melos.yaml create mode 100644 melos_patapata.iml create mode 100644 packages/patapata_adjust/.gitignore create mode 100644 packages/patapata_adjust/.metadata create mode 100644 packages/patapata_adjust/CHANGELOG.md create mode 100644 packages/patapata_adjust/LICENSE create mode 100644 packages/patapata_adjust/README.md create mode 100644 packages/patapata_adjust/analysis_options.yaml create mode 100644 packages/patapata_adjust/android/.gitignore create mode 100644 packages/patapata_adjust/android/build.gradle create mode 100644 packages/patapata_adjust/android/proguard-rules.pro create mode 100644 packages/patapata_adjust/android/settings.gradle create mode 100644 packages/patapata_adjust/android/src/main/AndroidManifest.xml create mode 100644 packages/patapata_adjust/android/src/main/kotlin/dev/patapata/patapata_adjust/PatapataAdjustPlugin.kt create mode 100644 packages/patapata_adjust/dartdoc_options.yaml create mode 100644 packages/patapata_adjust/ios/.gitignore create mode 100644 packages/patapata_adjust/ios/Assets/.gitkeep create mode 100644 packages/patapata_adjust/ios/Classes/PatapataAdjustPlugin.h create mode 100644 packages/patapata_adjust/ios/Classes/PatapataAdjustPlugin.m create mode 100644 packages/patapata_adjust/ios/Classes/SwiftPatapataAdjustPlugin.swift create mode 100644 packages/patapata_adjust/ios/patapata_adjust.podspec create mode 100644 packages/patapata_adjust/lib/patapata_adjust.dart create mode 100644 packages/patapata_adjust/pubspec.yaml create mode 100644 packages/patapata_adjust/test/patapata_adjust_test.dart create mode 100644 packages/patapata_apple_push_notifications/.gitignore create mode 100644 packages/patapata_apple_push_notifications/.metadata create mode 100644 packages/patapata_apple_push_notifications/CHANGELOG.md create mode 100644 packages/patapata_apple_push_notifications/LICENSE create mode 100644 packages/patapata_apple_push_notifications/README.md create mode 100644 packages/patapata_apple_push_notifications/analysis_options.yaml create mode 100644 packages/patapata_apple_push_notifications/dartdoc_options.yaml create mode 100644 packages/patapata_apple_push_notifications/example/.gitignore create mode 100644 packages/patapata_apple_push_notifications/example/.metadata create mode 100644 packages/patapata_apple_push_notifications/example/README.md create mode 100644 packages/patapata_apple_push_notifications/example/analysis_options.yaml create mode 100644 packages/patapata_apple_push_notifications/example/ios/.gitignore create mode 100644 packages/patapata_apple_push_notifications/example/ios/Flutter/AppFrameworkInfo.plist create mode 100644 packages/patapata_apple_push_notifications/example/ios/Flutter/Debug.xcconfig create mode 100644 packages/patapata_apple_push_notifications/example/ios/Flutter/Release.xcconfig create mode 100644 packages/patapata_apple_push_notifications/example/ios/Podfile create mode 100644 packages/patapata_apple_push_notifications/example/ios/Podfile.lock create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.pbxproj create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/AppDelegate.swift create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Base.lproj/Main.storyboard create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Info.plist create mode 100644 packages/patapata_apple_push_notifications/example/ios/Runner/Runner-Bridging-Header.h create mode 100644 packages/patapata_apple_push_notifications/example/lib/main.dart create mode 100644 packages/patapata_apple_push_notifications/example/pubspec.yaml create mode 100644 packages/patapata_apple_push_notifications/ios/.gitignore create mode 100644 packages/patapata_apple_push_notifications/ios/Assets/.gitkeep create mode 100644 packages/patapata_apple_push_notifications/ios/Classes/PatapataApplePushNotificationsPlugin.h create mode 100644 packages/patapata_apple_push_notifications/ios/Classes/PatapataApplePushNotificationsPlugin.m create mode 100644 packages/patapata_apple_push_notifications/ios/Classes/SwiftPatapataApplePushNotificationsPlugin.swift create mode 100644 packages/patapata_apple_push_notifications/ios/patapata_apple_push_notifications.podspec create mode 100644 packages/patapata_apple_push_notifications/lib/patapata_apple_push_notifications.dart create mode 100644 packages/patapata_apple_push_notifications/melos_patapata_apple_push_notifications.iml create mode 100644 packages/patapata_apple_push_notifications/pubspec.yaml create mode 100644 packages/patapata_apple_push_notifications/test/patapata_apple_push_notifications_test.dart create mode 100644 packages/patapata_apps_flyer/.gitignore create mode 100644 packages/patapata_apps_flyer/.metadata create mode 100644 packages/patapata_apps_flyer/CHANGELOG.md create mode 100644 packages/patapata_apps_flyer/LICENSE create mode 100644 packages/patapata_apps_flyer/README.md create mode 100644 packages/patapata_apps_flyer/analysis_options.yaml create mode 100644 packages/patapata_apps_flyer/dartdoc_options.yaml create mode 100644 packages/patapata_apps_flyer/ios/.gitignore create mode 100644 packages/patapata_apps_flyer/ios/Assets/.gitkeep create mode 100644 packages/patapata_apps_flyer/ios/Classes/PatapataAppsFlyerPlugin.h create mode 100644 packages/patapata_apps_flyer/ios/Classes/PatapataAppsFlyerPlugin.m create mode 100644 packages/patapata_apps_flyer/ios/Classes/SwiftPatapataAppsFlyerPlugin.swift create mode 100644 packages/patapata_apps_flyer/ios/patapata_apps_flyer.podspec create mode 100644 packages/patapata_apps_flyer/lib/patapata_apps_flyer.dart create mode 100644 packages/patapata_apps_flyer/pubspec.yaml create mode 100644 packages/patapata_apps_flyer/test/patapata_apps_flyer_test.dart create mode 100644 packages/patapata_core/.github/assets/logo_pata2_horizontal.png create mode 100644 packages/patapata_core/.gitignore create mode 100644 packages/patapata_core/.metadata create mode 100644 packages/patapata_core/.vscode/launch.json create mode 100644 packages/patapata_core/.vscode/settings.json create mode 100644 packages/patapata_core/CHANGELOG.md create mode 100644 packages/patapata_core/LICENSE create mode 100644 packages/patapata_core/README.md create mode 100644 packages/patapata_core/analysis_options.yaml create mode 100644 packages/patapata_core/android/.gitignore create mode 100644 packages/patapata_core/android/build.gradle create mode 100644 packages/patapata_core/android/gradle.properties create mode 100644 packages/patapata_core/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/patapata_core/android/proguard-rules.pro create mode 100644 packages/patapata_core/android/settings.gradle create mode 100644 packages/patapata_core/android/src/main/AndroidManifest.xml create mode 100644 packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/Error.kt create mode 100644 packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/NativeLocalConfig.kt create mode 100644 packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/PatapataCorePlugin.kt create mode 100644 packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/PatapataPlugin.kt create mode 100644 packages/patapata_core/bin/bootstrap.dart create mode 100644 packages/patapata_core/dartdoc_options.yaml create mode 100644 packages/patapata_core/ios/.gitignore create mode 100644 packages/patapata_core/ios/Assets/.gitkeep create mode 100644 packages/patapata_core/ios/Classes/NativeLocalConfig.swift create mode 100644 packages/patapata_core/ios/Classes/PatapataCorePlugin.swift create mode 100644 packages/patapata_core/ios/Classes/PatapataCorePluginBridge.h create mode 100644 packages/patapata_core/ios/Classes/PatapataCorePluginBridge.m create mode 100644 packages/patapata_core/ios/Classes/PatapataPlugin.swift create mode 100644 packages/patapata_core/ios/patapata_core.podspec create mode 100644 packages/patapata_core/lib/finder/local_config_finder.dart create mode 100644 packages/patapata_core/lib/patapata_core.dart create mode 100644 packages/patapata_core/lib/patapata_core_libs.dart create mode 100644 packages/patapata_core/lib/patapata_core_web.dart create mode 100644 packages/patapata_core/lib/patapata_widgets.dart create mode 100644 packages/patapata_core/lib/src/analytics.dart create mode 100644 packages/patapata_core/lib/src/app.dart create mode 100644 packages/patapata_core/lib/src/config.dart create mode 100644 packages/patapata_core/lib/src/device_info.dart create mode 100644 packages/patapata_core/lib/src/error.dart create mode 100644 packages/patapata_core/lib/src/exception.dart create mode 100644 packages/patapata_core/lib/src/fake_ffi.dart create mode 100644 packages/patapata_core/lib/src/i18n.dart create mode 100644 packages/patapata_core/lib/src/local_config.dart create mode 100644 packages/patapata_core/lib/src/log.dart create mode 100644 packages/patapata_core/lib/src/logic_state.dart create mode 100644 packages/patapata_core/lib/src/method_channel_test_mixin.dart create mode 100644 packages/patapata_core/lib/src/native_ffi_finder.dart create mode 100644 packages/patapata_core/lib/src/native_local_config.dart create mode 100644 packages/patapata_core/lib/src/native_local_config_finder.dart create mode 100644 packages/patapata_core/lib/src/network.dart create mode 100644 packages/patapata_core/lib/src/notifications.dart create mode 100644 packages/patapata_core/lib/src/package_info.dart create mode 100644 packages/patapata_core/lib/src/permissions.dart create mode 100644 packages/patapata_core/lib/src/plugin.dart create mode 100644 packages/patapata_core/lib/src/provider_model.dart create mode 100644 packages/patapata_core/lib/src/remote_config.dart create mode 100644 packages/patapata_core/lib/src/remote_messaging.dart create mode 100644 packages/patapata_core/lib/src/sequential_work_queue.dart create mode 100644 packages/patapata_core/lib/src/startup.dart create mode 100644 packages/patapata_core/lib/src/user.dart create mode 100644 packages/patapata_core/lib/src/util.dart create mode 100644 packages/patapata_core/lib/src/widgets/platform_dialog.dart create mode 100644 packages/patapata_core/lib/src/widgets/screen_layout.dart create mode 100644 packages/patapata_core/lib/src/widgets/standard_app.dart create mode 100644 packages/patapata_core/lib/src/widgets/standard_app_mixin.dart create mode 100644 packages/patapata_core/lib/src/widgets/standard_cupertino_app.dart create mode 100644 packages/patapata_core/lib/src/widgets/standard_material_app.dart create mode 100644 packages/patapata_core/lib/src/widgets/standard_page.dart create mode 100644 packages/patapata_core/lib/src/widgets/standard_page_widget.dart create mode 100644 packages/patapata_core/lib/web/patapata_web_plugin.dart create mode 100644 packages/patapata_core/lib/web/web_local_config.dart create mode 100644 packages/patapata_core/lib/web/web_local_config_finder.dart create mode 100644 packages/patapata_core/macos/.gitignore create mode 100644 packages/patapata_core/macos/Classes/NativeLocalConfig.swift create mode 100644 packages/patapata_core/macos/Classes/PatapataCorePlugin.swift create mode 100644 packages/patapata_core/macos/Classes/PatapataCorePluginBridge.h create mode 100644 packages/patapata_core/macos/Classes/PatapataCorePluginBridge.m create mode 100644 packages/patapata_core/macos/Classes/PatapataPlugin.swift create mode 100644 packages/patapata_core/macos/patapata_core.podspec create mode 100644 packages/patapata_core/makefile create mode 100644 packages/patapata_core/pubspec.yaml create mode 100644 packages/patapata_core/test/analytics_test.dart create mode 100644 packages/patapata_core/test/app_test.dart create mode 100644 packages/patapata_core/test/assets/images/logo.svg create mode 100644 packages/patapata_core/test/device_info_test.dart create mode 100644 packages/patapata_core/test/error_test.dart create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_BreakPoint_CopyTest.png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(1668.0, 2224.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(1668.0, 2388.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(1920.0, 1080.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(2048.0, 2732.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(375.0, 667.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(375.0, 812.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(414.0, 736.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(414.0, 896.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(768.0, 1024.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(1668.0, 2224.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(1668.0, 2388.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(1920.0, 1080.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(2048.0, 2732.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(375.0, 667.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(375.0, 812.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(414.0, 736.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(414.0, 896.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(768.0, 1024.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(1668.0, 2224.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(1668.0, 2388.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(1920.0, 1080.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(2048.0, 2732.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(375.0, 667.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(375.0, 812.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(414.0, 736.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(414.0, 896.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(667.0, 375.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Size(768.0, 1024.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Transform_Disable_Size(414.0, 736.0).png create mode 100644 packages/patapata_core/test/goldens/ScreenLayout_Transform_Size(414.0, 736.0).png create mode 100644 packages/patapata_core/test/i18n_test.dart create mode 100644 packages/patapata_core/test/local_config_plugin_test.dart create mode 100644 packages/patapata_core/test/log_test.dart create mode 100644 packages/patapata_core/test/logic_state_test.dart create mode 100644 packages/patapata_core/test/native_local_config_test.dart create mode 100644 packages/patapata_core/test/network_test.dart create mode 100644 packages/patapata_core/test/notifications_test.dart create mode 100644 packages/patapata_core/test/package_info_test.dart create mode 100644 packages/patapata_core/test/pages/error_page.dart create mode 100644 packages/patapata_core/test/pages/home_page.dart create mode 100644 packages/patapata_core/test/pages/notification_page.dart create mode 100644 packages/patapata_core/test/pages/startup_page.dart create mode 100644 packages/patapata_core/test/permission/permissions_for_android_test.dart create mode 100644 packages/patapata_core/test/permission/permissions_for_ios_test.dart create mode 100644 packages/patapata_core/test/permission/permissions_for_macos_test.dart create mode 100644 packages/patapata_core/test/platform_dialog_test.dart create mode 100644 packages/patapata_core/test/plugin_test.dart create mode 100644 packages/patapata_core/test/provider_model_test.dart create mode 100644 packages/patapata_core/test/proxy_local_config_test.dart create mode 100644 packages/patapata_core/test/remote_config_test.dart create mode 100644 packages/patapata_core/test/remote_messaging_test.dart create mode 100644 packages/patapata_core/test/screen_layout_test.dart create mode 100644 packages/patapata_core/test/sequential_work_queue_test.dart create mode 100644 packages/patapata_core/test/standard_app_logic_test.dart create mode 100644 packages/patapata_core/test/standard_app_widget_tab_test.dart create mode 100644 packages/patapata_core/test/standard_app_widget_test.dart create mode 100644 packages/patapata_core/test/startup_test.dart create mode 100644 packages/patapata_core/test/user_test.dart create mode 100644 packages/patapata_core/test/util_test.dart create mode 100644 packages/patapata_core/test/utils/patapata_core_test_utils.dart create mode 100644 packages/patapata_core/test/utils/standard_app_widget_test_data.dart create mode 100644 packages/patapata_example_app/.gitignore create mode 100644 packages/patapata_example_app/.metadata create mode 100644 packages/patapata_example_app/README.md create mode 100644 packages/patapata_example_app/analysis_options.yaml create mode 100644 packages/patapata_example_app/android/.gitignore create mode 100644 packages/patapata_example_app/android/app/build.gradle create mode 100644 packages/patapata_example_app/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/patapata_example_app/android/app/src/main/AndroidManifest.xml create mode 100644 packages/patapata_example_app/android/app/src/main/kotlin/com/example/patapata_example_app/MainActivity.kt create mode 100644 packages/patapata_example_app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 packages/patapata_example_app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 packages/patapata_example_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 packages/patapata_example_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 packages/patapata_example_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 packages/patapata_example_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 packages/patapata_example_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 packages/patapata_example_app/android/app/src/main/res/values-night/styles.xml create mode 100644 packages/patapata_example_app/android/app/src/main/res/values/styles.xml create mode 100644 packages/patapata_example_app/android/app/src/profile/AndroidManifest.xml create mode 100644 packages/patapata_example_app/android/build.gradle create mode 100644 packages/patapata_example_app/android/gradle.properties create mode 100644 packages/patapata_example_app/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/patapata_example_app/android/settings.gradle create mode 100644 packages/patapata_example_app/ios/.gitignore create mode 100644 packages/patapata_example_app/ios/Flutter/AppFrameworkInfo.plist create mode 100644 packages/patapata_example_app/ios/Flutter/Debug.xcconfig create mode 100644 packages/patapata_example_app/ios/Flutter/Release.xcconfig create mode 100644 packages/patapata_example_app/ios/Podfile create mode 100644 packages/patapata_example_app/ios/Podfile.lock create mode 100644 packages/patapata_example_app/ios/Runner.xcodeproj/project.pbxproj create mode 100644 packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 packages/patapata_example_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/patapata_example_app/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 packages/patapata_example_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/patapata_example_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 packages/patapata_example_app/ios/Runner/AppDelegate.swift create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 packages/patapata_example_app/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 packages/patapata_example_app/ios/Runner/Base.lproj/Main.storyboard create mode 100644 packages/patapata_example_app/ios/Runner/Info.plist create mode 100644 packages/patapata_example_app/ios/Runner/Runner-Bridging-Header.h create mode 100644 packages/patapata_example_app/ios/RunnerTests/RunnerTests.swift create mode 100644 packages/patapata_example_app/l10n/ar.yaml create mode 100644 packages/patapata_example_app/l10n/en.yaml create mode 100644 packages/patapata_example_app/l10n/ja.yaml create mode 100644 packages/patapata_example_app/lib/exceptions.dart create mode 100644 packages/patapata_example_app/lib/main.dart create mode 100644 packages/patapata_example_app/lib/page_data.dart create mode 100644 packages/patapata_example_app/lib/src/cupertino/pages/home_page.dart create mode 100644 packages/patapata_example_app/lib/src/cupertino/pages/my_page.dart create mode 100644 packages/patapata_example_app/lib/src/cupertino/pages/top_page.dart create mode 100644 packages/patapata_example_app/lib/src/cupertino/widgets/app_tab.dart create mode 100644 packages/patapata_example_app/lib/src/environment.dart create mode 100644 packages/patapata_example_app/lib/src/errors.dart create mode 100644 packages/patapata_example_app/lib/src/pages/agreement_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/config_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/device_and_package_info_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/error_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/home_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/my_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/screen_layout_example_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/splash_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/standard_page_example_page.dart create mode 100644 packages/patapata_example_app/lib/src/pages/top_page.dart create mode 100644 packages/patapata_example_app/lib/src/startup.dart create mode 100644 packages/patapata_example_app/lib/src/widgets/app_tab.dart create mode 100644 packages/patapata_example_app/linux/.gitignore create mode 100644 packages/patapata_example_app/linux/CMakeLists.txt create mode 100644 packages/patapata_example_app/linux/flutter/CMakeLists.txt create mode 100644 packages/patapata_example_app/linux/flutter/generated_plugin_registrant.cc create mode 100644 packages/patapata_example_app/linux/flutter/generated_plugin_registrant.h create mode 100644 packages/patapata_example_app/linux/flutter/generated_plugins.cmake create mode 100644 packages/patapata_example_app/linux/main.cc create mode 100644 packages/patapata_example_app/linux/my_application.cc create mode 100644 packages/patapata_example_app/linux/my_application.h create mode 100644 packages/patapata_example_app/macos/.gitignore create mode 100644 packages/patapata_example_app/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 packages/patapata_example_app/macos/Flutter/Flutter-Release.xcconfig create mode 100644 packages/patapata_example_app/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 packages/patapata_example_app/macos/Podfile create mode 100644 packages/patapata_example_app/macos/Runner.xcodeproj/project.pbxproj create mode 100644 packages/patapata_example_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/patapata_example_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 packages/patapata_example_app/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 packages/patapata_example_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/patapata_example_app/macos/Runner/AppDelegate.swift create mode 100644 packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 packages/patapata_example_app/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 packages/patapata_example_app/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 packages/patapata_example_app/macos/Runner/Configs/Debug.xcconfig create mode 100644 packages/patapata_example_app/macos/Runner/Configs/Release.xcconfig create mode 100644 packages/patapata_example_app/macos/Runner/Configs/Warnings.xcconfig create mode 100644 packages/patapata_example_app/macos/Runner/DebugProfile.entitlements create mode 100644 packages/patapata_example_app/macos/Runner/Info.plist create mode 100644 packages/patapata_example_app/macos/Runner/MainFlutterWindow.swift create mode 100644 packages/patapata_example_app/macos/Runner/Release.entitlements create mode 100644 packages/patapata_example_app/macos/RunnerTests/RunnerTests.swift create mode 100644 packages/patapata_example_app/pubspec.yaml create mode 100644 packages/patapata_example_app/web/favicon.png create mode 100644 packages/patapata_example_app/web/icons/Icon-192.png create mode 100644 packages/patapata_example_app/web/icons/Icon-512.png create mode 100644 packages/patapata_example_app/web/icons/Icon-maskable-192.png create mode 100644 packages/patapata_example_app/web/icons/Icon-maskable-512.png create mode 100644 packages/patapata_example_app/web/index.html create mode 100644 packages/patapata_example_app/web/manifest.json create mode 100644 packages/patapata_example_app/windows/.gitignore create mode 100644 packages/patapata_example_app/windows/CMakeLists.txt create mode 100644 packages/patapata_example_app/windows/flutter/CMakeLists.txt create mode 100644 packages/patapata_example_app/windows/flutter/generated_plugin_registrant.cc create mode 100644 packages/patapata_example_app/windows/flutter/generated_plugin_registrant.h create mode 100644 packages/patapata_example_app/windows/flutter/generated_plugins.cmake create mode 100644 packages/patapata_example_app/windows/runner/CMakeLists.txt create mode 100644 packages/patapata_example_app/windows/runner/Runner.rc create mode 100644 packages/patapata_example_app/windows/runner/flutter_window.cpp create mode 100644 packages/patapata_example_app/windows/runner/flutter_window.h create mode 100644 packages/patapata_example_app/windows/runner/main.cpp create mode 100644 packages/patapata_example_app/windows/runner/resource.h create mode 100644 packages/patapata_example_app/windows/runner/resources/app_icon.ico create mode 100644 packages/patapata_example_app/windows/runner/runner.exe.manifest create mode 100644 packages/patapata_example_app/windows/runner/utils.cpp create mode 100644 packages/patapata_example_app/windows/runner/utils.h create mode 100644 packages/patapata_example_app/windows/runner/win32_window.cpp create mode 100644 packages/patapata_example_app/windows/runner/win32_window.h create mode 100644 packages/patapata_firebase_analytics/.gitignore create mode 100644 packages/patapata_firebase_analytics/.metadata create mode 100644 packages/patapata_firebase_analytics/CHANGELOG.md create mode 100644 packages/patapata_firebase_analytics/LICENSE create mode 100644 packages/patapata_firebase_analytics/README.md create mode 100644 packages/patapata_firebase_analytics/analysis_options.yaml create mode 100644 packages/patapata_firebase_analytics/dartdoc_options.yaml create mode 100644 packages/patapata_firebase_analytics/lib/patapata_firebase_analytics.dart create mode 100644 packages/patapata_firebase_analytics/pubspec.yaml create mode 100644 packages/patapata_firebase_analytics/test/patapata_firebase_analytics_test.dart create mode 100644 packages/patapata_firebase_auth/.gitignore create mode 100644 packages/patapata_firebase_auth/.metadata create mode 100644 packages/patapata_firebase_auth/CHANGELOG.md create mode 100644 packages/patapata_firebase_auth/LICENSE create mode 100644 packages/patapata_firebase_auth/README.md create mode 100644 packages/patapata_firebase_auth/analysis_options.yaml create mode 100644 packages/patapata_firebase_auth/lib/patapata_firebase_auth.dart create mode 100644 packages/patapata_firebase_auth/pubspec.yaml create mode 100644 packages/patapata_firebase_auth/test/patapata_firebase_auth_test.dart create mode 100644 packages/patapata_firebase_core/.gitignore create mode 100644 packages/patapata_firebase_core/.metadata create mode 100644 packages/patapata_firebase_core/CHANGELOG.md create mode 100644 packages/patapata_firebase_core/LICENSE create mode 100644 packages/patapata_firebase_core/README.md create mode 100644 packages/patapata_firebase_core/analysis_options.yaml create mode 100644 packages/patapata_firebase_core/dartdoc_options.yaml create mode 100644 packages/patapata_firebase_core/lib/patapata_firebase_core.dart create mode 100644 packages/patapata_firebase_core/pubspec.yaml create mode 100644 packages/patapata_firebase_core/test/patapata_firebase_core_test.dart create mode 100644 packages/patapata_firebase_crashlytics/.gitignore create mode 100644 packages/patapata_firebase_crashlytics/.metadata create mode 100644 packages/patapata_firebase_crashlytics/CHANGELOG.md create mode 100644 packages/patapata_firebase_crashlytics/LICENSE create mode 100644 packages/patapata_firebase_crashlytics/README.md create mode 100644 packages/patapata_firebase_crashlytics/analysis_options.yaml create mode 100644 packages/patapata_firebase_crashlytics/dartdoc_options.yaml create mode 100644 packages/patapata_firebase_crashlytics/lib/patapata_firebase_crashlytics.dart create mode 100644 packages/patapata_firebase_crashlytics/pubspec.yaml create mode 100644 packages/patapata_firebase_crashlytics/test/patapata_firebase_crashlytics_test.dart create mode 100644 packages/patapata_firebase_dynamic_links/.gitignore create mode 100644 packages/patapata_firebase_dynamic_links/.metadata create mode 100644 packages/patapata_firebase_dynamic_links/CHANGELOG.md create mode 100644 packages/patapata_firebase_dynamic_links/LICENSE create mode 100644 packages/patapata_firebase_dynamic_links/README.md create mode 100644 packages/patapata_firebase_dynamic_links/analysis_options.yaml create mode 100644 packages/patapata_firebase_dynamic_links/dartdoc_options.yaml create mode 100644 packages/patapata_firebase_dynamic_links/lib/patapata_firebase_dynamic_links.dart create mode 100644 packages/patapata_firebase_dynamic_links/pubspec.yaml create mode 100644 packages/patapata_firebase_dynamic_links/test/patapata_firebase_dynamic_links_test.dart create mode 100644 packages/patapata_firebase_messaging/.gitignore create mode 100644 packages/patapata_firebase_messaging/.metadata create mode 100644 packages/patapata_firebase_messaging/CHANGELOG.md create mode 100644 packages/patapata_firebase_messaging/LICENSE create mode 100644 packages/patapata_firebase_messaging/README.md create mode 100644 packages/patapata_firebase_messaging/analysis_options.yaml create mode 100644 packages/patapata_firebase_messaging/android/.gitignore create mode 100644 packages/patapata_firebase_messaging/android/build.gradle create mode 100644 packages/patapata_firebase_messaging/android/settings.gradle create mode 100644 packages/patapata_firebase_messaging/android/src/main/AndroidManifest.xml create mode 100644 packages/patapata_firebase_messaging/android/src/main/kotlin/dev/patapata/patapata_firebase_messaging/PatapataFirebaseMessagingPlugin.kt create mode 100644 packages/patapata_firebase_messaging/dartdoc_options.yaml create mode 100644 packages/patapata_firebase_messaging/lib/patapata_firebase_messaging.dart create mode 100644 packages/patapata_firebase_messaging/pubspec.yaml create mode 100644 packages/patapata_firebase_messaging/test/patapata_firebase_messaging_test.dart create mode 100644 packages/patapata_firebase_remote_config/.gitignore create mode 100644 packages/patapata_firebase_remote_config/.metadata create mode 100644 packages/patapata_firebase_remote_config/CHANGELOG.md create mode 100644 packages/patapata_firebase_remote_config/LICENSE create mode 100644 packages/patapata_firebase_remote_config/README.md create mode 100644 packages/patapata_firebase_remote_config/analysis_options.yaml create mode 100644 packages/patapata_firebase_remote_config/dartdoc_options.yaml create mode 100644 packages/patapata_firebase_remote_config/lib/patapata_firebase_remote_config.dart create mode 100644 packages/patapata_firebase_remote_config/pubspec.yaml create mode 100644 packages/patapata_firebase_remote_config/test/patapata_firebase_remote_config_test.dart create mode 100644 packages/patapata_karte_core/.gitignore create mode 100644 packages/patapata_karte_core/.metadata create mode 100644 packages/patapata_karte_core/CHANGELOG.md create mode 100644 packages/patapata_karte_core/LICENSE create mode 100644 packages/patapata_karte_core/README.md create mode 100644 packages/patapata_karte_core/analysis_options.yaml create mode 100644 packages/patapata_karte_core/android/.gitignore create mode 100644 packages/patapata_karte_core/android/build.gradle create mode 100644 packages/patapata_karte_core/android/gradle.properties create mode 100644 packages/patapata_karte_core/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/patapata_karte_core/android/settings.gradle create mode 100644 packages/patapata_karte_core/android/src/main/AndroidManifest.xml create mode 100644 packages/patapata_karte_core/android/src/main/kotlin/dev/patapata/patapata_karte_core/PatapataKarteCorePlugin.kt create mode 100644 packages/patapata_karte_core/dartdoc_options.yaml create mode 100644 packages/patapata_karte_core/ios/.gitignore create mode 100644 packages/patapata_karte_core/ios/Assets/.gitkeep create mode 100644 packages/patapata_karte_core/ios/Classes/PatapataKarteCorePlugin.h create mode 100644 packages/patapata_karte_core/ios/Classes/PatapataKarteCorePlugin.m create mode 100644 packages/patapata_karte_core/ios/Classes/SwiftPatapataKarteCorePlugin.swift create mode 100644 packages/patapata_karte_core/ios/patapata_karte_core.podspec create mode 100644 packages/patapata_karte_core/lib/patapata_karte_core.dart create mode 100644 packages/patapata_karte_core/melos_patapata_karte_core.iml create mode 100644 packages/patapata_karte_core/pubspec.yaml create mode 100644 packages/patapata_karte_core/test/patapata_karte_core_test.dart create mode 100644 packages/patapata_karte_variables/.gitignore create mode 100644 packages/patapata_karte_variables/.metadata create mode 100644 packages/patapata_karte_variables/CHANGELOG.md create mode 100644 packages/patapata_karte_variables/LICENSE create mode 100644 packages/patapata_karte_variables/README.md create mode 100644 packages/patapata_karte_variables/analysis_options.yaml create mode 100644 packages/patapata_karte_variables/dartdoc_options.yaml create mode 100644 packages/patapata_karte_variables/lib/patapata_karte_variables.dart create mode 100644 packages/patapata_karte_variables/pubspec.yaml create mode 100644 packages/patapata_karte_variables/test/patapata_karte_variables_test.dart create mode 100644 packages/patapata_riverpod/.gitignore create mode 100644 packages/patapata_riverpod/.metadata create mode 100644 packages/patapata_riverpod/CHANGELOG.md create mode 100644 packages/patapata_riverpod/LICENSE create mode 100644 packages/patapata_riverpod/README.md create mode 100644 packages/patapata_riverpod/analysis_options.yaml create mode 100644 packages/patapata_riverpod/lib/patapata_riverpod.dart create mode 100644 packages/patapata_riverpod/lib/src/providers.dart create mode 100644 packages/patapata_riverpod/lib/src/providers.g.dart create mode 100644 packages/patapata_riverpod/lib/src/riverpod_plugin.dart create mode 100644 packages/patapata_riverpod/pubspec.yaml create mode 100644 packages/patapata_riverpod/test/patapata_riverpod_test.dart create mode 100644 packages/patapata_sentry/.gitignore create mode 100644 packages/patapata_sentry/.metadata create mode 100644 packages/patapata_sentry/CHANGELOG.md create mode 100644 packages/patapata_sentry/LICENSE create mode 100644 packages/patapata_sentry/README.md create mode 100644 packages/patapata_sentry/analysis_options.yaml create mode 100644 packages/patapata_sentry/dartdoc_options.yaml create mode 100644 packages/patapata_sentry/lib/patapata_sentry.dart create mode 100644 packages/patapata_sentry/pubspec.yaml create mode 100644 packages/patapata_sentry/test/patapata_sentry_test.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100755 tools/dartdoc_all.sh create mode 100755 tools/pub_get_all.sh create mode 100755 tools/pub_upgrade_all.sh create mode 100755 tools/test_all.sh diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..4be6799 --- /dev/null +++ b/.clang-format @@ -0,0 +1,3 @@ +BasedOnStyle: Google +IndentWidth: 2 +AlwaysBreakTemplateDeclarations: Yes \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b170eb0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# editorconfig +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +quote_type = single \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/1_bug_report.md b/.github/ISSUE_TEMPLATE/1_bug_report.md new file mode 100644 index 0000000..0d60ca3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_bug_report.md @@ -0,0 +1,43 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +# Report a bug +Thank you for using Patapata. This is an issue to report any problems or bugs related to Patapata's features. Please provide the steps to reproduce the issue, the results of those steps, logs, Flutter doctor output, and any sample code if available. + +## Description +- + +### Reproduction Steps +- + +### Reproduction Steps Result +- + +### Environment +- + + +### Logs +- + + +### **Flutter Doctor Result** +- + diff --git a/.github/ISSUE_TEMPLATE/2_feature_request.md b/.github/ISSUE_TEMPLATE/2_feature_request.md new file mode 100644 index 0000000..52a3c93 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +# Feature Request +Thank you for using Patapata. This is an issue for requesting new features for Patapata. Please provide the details of the feature you would like to add to Patapata. + +## Description +- + + +## Similar Features +- + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/3_other.md b/.github/ISSUE_TEMPLATE/3_other.md new file mode 100644 index 0000000..558e9cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_other.md @@ -0,0 +1,15 @@ +--- +name: Other issue +about: Create regarding other issues +title: '' +labels: '' +assignees: '' + +--- + +# Other Issue +Thank you for using Patapata. This is an issue for reporting bugs, problems, or making suggestions other than adding new features. + +## Description +- + \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..917ef9e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ + + +# Issue Number +#{IssueNumber} + + +# Summary +- + \ No newline at end of file diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..2857fb7 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,58 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Check + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + flutterVersion: + - '3.19.3' + - '3.16.9' + - '3.13.9' + - '3.13.0' + + steps: + - uses: actions/checkout@v3 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ matrix.flutterVersion }} + channel: 'stable' + cache: true + + - name: Setup Go environment + uses: actions/setup-go@v4.1.0 + + - name: Install dependencies + run: |- + go install github.com/google/addlicense@latest + flutter pub get + dart pub global activate melos + melos bootstrap + + - name: Analyze project source + run: melos run lint:all + + - name: Run tests + run: melos run test:all + + - name: Check license headers + run: melos run check-license-header + + - name: Test building Android + run: melos run build:example_android + + - name: Test building web + run: melos run build:example_web diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000..bc3e793 --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,32 @@ +name: Upload Codecov + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + working-directory: ./packages/patapata_core + + - name: Run tests for coverage + run: flutter test --dart-define=IS_TEST=true --no-pub --coverage + working-directory: ./packages/patapata_core + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1353e81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.vscode/* +.DS_Store +**/coverage +pubspec_overrides.yaml +.dart_tool +.melos_tool +.atom +.idea +.packages +.pub +.symlinks +pubspec.lock + +! .vscode/launch.json diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..6b8f821 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,6 @@ +--indent 2 +--maxwidth 100 +--wrapparameters afterfirst +--disable sortedImports,unusedArguments,wrapMultilineStatementBraces +--exclude Pods,**/MainFlutterWindow.swift,**/AppDelegate.swift,**/.symlinks/** +--swiftversion 5.7 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e4d87c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..488e9f8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,141 @@ +# Contribution to Patapata + +This is a guideline on how to contribute to Patapata. By following these guidelines, anyone can contribute to Patapata. + +## Issues + +If you have any issues related to Patapata, please report them. When you do, use a template from GitHub Issues. + +- Report errors here in this Issue. +- Share your opinions on questions or feature requests related to Patapata here in this Issue. +- For any other questions, please use this Issue. + +## Pull Request + +If you have any modifications related to Patapata, please submit a PR (Pull Request). In this case, please adhere to the following guidelines: + +- Submit the PR against the next branch. +- Fill in the necessary items in the template when creating the PR. +- Assign it an issue number and appropriate labels. +- Ensure it meets the test and coverage requirements with `melos run test:all` or manually with `flutter test --dart-define=IS_TEST=true` for all plugins and core. +- Generally, if you're adding additional Dart files, include their corresponding test code as well. + +## Copyright Notice + +Patapata uses the MIT License. The rights to this open-source software are owned by GREE, Inc., and the company retains these rights even after the software is publicly released. + +Regarding modifications made by individuals other than GREE, Inc., the rights to the modified portions belong to the person who made the modifications. + +When creating source code in languages like Dart, Kotlin, Swift, etc., it is essential to include the MIT License at the beginning of the file. Please follow the formatting guidelines specific to the programming language. Below, I'll provide an example of the license statements that are typically included in Dart, Kotlin, and Swift files managed in Patapata: + +dart + +```dart +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +``` + +Kotlin + +```kotlin +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +``` + +Swift + +```swift +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +``` + +You can also use the melos command to automatically add the license statement to the file. + +`melos run add-license-header` + +Note that you will need a working go environment with [addlicense](https://github.com/google/addlicense) installed to use this command. + +## Coding Conventions + +- We generally follow the [effective-dart](https://dart.dev/guides/language/effective-dart/style) guidelines. +- We adhere to the [Flutter formatting rules](https://flutter.dev/docs/development/tools/formatting). +- We follow the rules of the [Linter](https://dart-lang.github.io/linter/lints/index.html). + +### Other Coding Conventions + +- Start all local variables with 't,' where 't' stands for temporary. We do this to instantly understand if a variable will affect something outside of the current scope without having to manually looking at the declaration. + +```dart +void main() async { + final App tApp = createApp(); + ... + tApp.run(); +} +``` + +- Iterator-related variables in constructs like `for` loops can remain as 'i' or similar; there's no issue with that. + +```dart + for (var i = 0; i < 10; i++) { + print("count : $i"); + } +``` + +- For internal function names, begin with 'f.' We do this to instantly understand when a function is a local function versus anything else. For example: + +```dart + void exampleFunc() { + fHogeHoge() { + ... + } + + fHogeHoge(); + } +``` + +- Start private static variables with '_s.’ This is to instantly understand that these variables are static and will possibly affect multiple areas of the codebase as well as not being tied to the current instance. + +```dart +static var _sCounter = 0; +``` + +- For constants, begin private ones with '_k' and public ones with 'k.’ + +```dart +const _kMyNum = 1; +const kMyPublicNum = 1; +``` + +- After an 'if (...)' statement, always include curly braces '{}' and a newline. Avoid writing it on a single line without curly braces. For if expressions this is not required. + +```dart +// OK +if (something) { + doThat(); +} + +// NG +if (something) doThat(); + +// OK +final tArray = [ + if (something) 2, +]; +``` + +## Project Management + +Patapata uses [melos](https://melos.invertase.dev/) to manage this monorepository. Please refer to the melos documentation for more information. + +## Quality Assurance + +- Patapata aims to support the latest Flutter version as much as possible. However, there may be times when it cannot be compatible with that version due to Flutter's update changes. +- Patapata strives to achieve as close to 100% test code coverage as possible. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d97508 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +
+

Patapata

+ +
+ +
+ +

+ A collection of best-practices and tools for building applications quickly and reliably. +

+ +

+ Project Homepage +

+ +

+ + GitHub Workflow Status (branch) + + + Pub Popularity + + + Maintained with Melos + + + License + + + + +

+ +
+ +--- + +[[Changelog]](https://github.com/gree/patapata/blob/main/CHANGELOG.md) • [[Packages]](https://pub.dev/publishers/gree.co.jp/packages) + +--- + +Patapata is a framework built on Flutter for creating applications of production quality quickly and reliably. +It provides a collection of best-practices built directly in to the various APIs so you can build apps that are consistent, stable, and performant. + +[Flutter](https://flutter.dev) is Google’s UI toolkit for building beautiful, natively compiled applications for mobile, +web, and desktop from a single codebase. Flutter is used by developers and organizations around the world, and is free +and open source. + +--- + +## Documentation + +**If you just want to jump in and get Patapata working without any plugins or reading the documentation, check out [![patapata_core pub.dev badge](https://img.shields.io/pub/v/patapata_core.svg?label=patapata_core)](https://pub.dev/packages/patapata_core).** + +Most of our documentation is within the [patapata_core](https://github.com/gree/patapata/blob/main/packages/patapata_core/README.md) package, so start there. + +If you want documentation on the various plugins we provide, check out the plugin sections below. + +## Stable Plugins + +| Name | pub.dev | Related Product | Documentation | View Source | Android | iOS | Web | MacOS | Windows | Linux +|------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------:|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------:|:---------:|:-----:|:-----:|:-------:|:-------:|:-------:| +| Apple Push Notifications | [![patapata_apple_push_notifications pub.dev badge](https://img.shields.io/pub/v/patapata_apple_push_notifications.svg)](https://pub.dev/packages/patapata_apple_push_notifications) | [🔗](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns) | [📖](https://pub.dev/documentation/patapata_apple_push_notifications/latest/) | [`patapata_apple_push_notifications`](https://github.com/gree/patapata/tree/main/packages/patapata_apple_push_notifications) | ✖︎ | ✔ | ✖︎ | ✔ | ✖︎ | ✖︎ | +| Firebase Analytics | [![patapata_firebase_analytics pub.dev badge](https://img.shields.io/pub/v/patapata_firebase_analytics.svg)](https://pub.dev/packages/patapata_firebase_analytics) | [🔗](https://firebase.google.com/docs/flutter/setup) | [📖](https://pub.dev/documentation/patapata_firebase_analytics/latest/) | [`patapata_firebase_analytics`](https://github.com/gree/patapata/tree/main/packages/patapata_firebase_analytics) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Firebase Auth | [![patapata_firebase_auth pub.dev badge](https://img.shields.io/pub/v/patapata_firebase_auth.svg)](https://pub.dev/packages/patapata_firebase_auth) | [🔗](https://firebase.google.com/docs/flutter/setup) | [📖](https://pub.dev/documentation/patapata_firebase_auth/latest/) | [`patapata_firebase_auth`](https://github.com/gree/patapata/tree/main/packages/patapata_firebase_auth) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Firebase Core | [![patapata_firebase_core pub.dev badge](https://img.shields.io/pub/v/patapata_firebase_core.svg)](https://pub.dev/packages/patapata_firebase_core) | [🔗](https://firebase.google.com/docs/flutter/setup) | [📖](https://pub.dev/documentation/patapata_firebase_core/latest/) | [`patapata_firebase_core`](https://github.com/gree/patapata/tree/main/packages/patapata_firebase_core) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Firebase Crashlytics | [![patapata_firebase_crashlytics pub.dev badge](https://img.shields.io/pub/v/patapata_firebase_crashlytics.svg)](https://pub.dev/packages/patapata_firebase_crashlytics) | [🔗](https://firebase.google.com/docs/flutter/setup) | [📖](https://pub.dev/documentation/patapata_firebase_crashlytics/latest/) | [`patapata_firebase_crashlytics`](https://github.com/gree/patapata/tree/main/packages/patapata_firebase_crashlytics) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Firebase Messaging | [![patapata_firebase_messaging pub.dev badge](https://img.shields.io/pub/v/patapata_firebase_messaging.svg)](https://pub.dev/packages/patapata_firebase_messaging) | [🔗](https://firebase.google.com/docs/flutter/setup) | [📖](https://pub.dev/documentation/patapata_firebase_messaging/latest/) | [`patapata_firebase_messaging`](https://github.com/gree/patapata/tree/main/packages/patapata_firebase_messaging) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Firebase Remote Config | [![patapata_firebase_remote_config pub.dev badge](https://img.shields.io/pub/v/patapata_firebase_remote_config.svg)](https://pub.dev/packages/patapata_firebase_remote_config) | [🔗](https://firebase.google.com/docs/flutter/setup) | [📖](https://pub.dev/documentation/patapata_firebase_remote_config/latest/) | [`patapata_firebase_remote_config`](https://github.com/gree/patapata/tree/main/packages/patapata_firebase_remote_config) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Sentry | [![patapata_sentry pub.dev badge](https://img.shields.io/pub/v/patapata_sentry.svg)](https://pub.dev/packages/patapata_sentry) | [🔗](https://sentry.io/welcome/) | [📖](https://pub.dev/documentation/patapata_sentry/latest/) | [`patapata_sentry`](https://github.com/gree/patapata/tree/main/packages/patapata_sentry) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Riverpod | [![patapata_riverpod pub.dev badge](https://img.shields.io/pub/v/patapata_riverpod.svg)](https://pub.dev/packages/patapata_riverpod) | [🔗](https://riverpod.dev/) | [📖](https://pub.dev/documentation/patapata_riverpod/latest/) | [`patapata_riverpod`](https://github.com/gree/patapata/tree/main/packages/patapata_riverpod) | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | + +## Unreleased But Useable Plugins +| Name | Related Product | Documentation | View Source | Android | iOS | Web | MacOS | Windows | Linux +|------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------:|:---------:|:-----:|:-----:|:-------:|:-------:|:-------:| +| Adjust | [🔗](https://www.adjust.com/) | [📖](https://pub.dev/documentation/patapata_adjust/latest/) | [`patapata_adjust`](https://github.com/gree/patapata/tree/main/packages/patapata_adjust) | ✔ | ✔ | ✔ | ✖︎ | ✖︎ | ✖︎ | +| AppsFlyer | [🔗](https://www.appsflyer.com/) | [📖](https://pub.dev/documentation/patapata_apps_flyer/latest/) | [`patapata_apps_flyer`](https://github.com/gree/patapata/tree/main/packages/patapata_apps_flyer) | ✔ | ✔ | ✖︎ | ✖︎ | ✖︎ | ✖︎ | +| Firebase Dynamic Links | [🔗](https://firebase.google.com/docs/flutter/setup) | [📖](https://pub.dev/documentation/patapata_firebase_dynamic_links/latest/) | [`patapata_firebase_dynamic_links`](https://github.com/gree/patapata/tree/main/packages/patapata_firebase_dynamic_links) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Karte Core | [🔗](https://karte.io/) | [📖](https://pub.dev/documentation/patapata_karte_core/latest/) | [`patapata_karte_core`](https://github.com/gree/patapata/tree/main/packages/patapata_karte_core) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | +| Karte Variables | [🔗](https://karte.io/) | [📖](https://pub.dev/documentation/patapata_karte_variables/latest/) | [`patapata_karte_variables`](https://github.com/gree/patapata/tree/main/packages/patapata_karte_variables) | ✔ | ✔ | ✔ | β | ✖︎ | ✖︎ | + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/LICENSE) \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..87022c7 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,8 @@ +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + # Ignore generated files + - '**/*.g.dart' diff --git a/assets/logo_pata2_horizontal.png b/assets/logo_pata2_horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..c1b341959849d5abda8790099b0f48a2158dac21 GIT binary patch literal 9695 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 cW5f#{{M + melos exec -c 6 -- "flutter clean" + + add-license-header: + # If you add here another --ignore flag, add it also to + # "check-license-header". + run: | + addlicense -f header_template.txt \ + --ignore "**/*.yml" \ + --ignore "**/*.yaml" \ + --ignore "**/*.xml" \ + --ignore "**/*.g.dart" \ + --ignore "**/*.sh" \ + --ignore "**/*.html" \ + --ignore "**/*.js" \ + --ignore "**/*.ts" \ + --ignore "**/*.g.h" \ + --ignore "**/*.g.m" \ + --ignore "**/*.rb" \ + --ignore "**/*.txt" \ + --ignore "**/*.cmake" \ + --ignore "**/doc/**" \ + --ignore "**/.dart_tool/**" \ + --ignore "**/Runner/MainFlutterWindow.swift" \ + --ignore "**/Runner/Runner-Bridging-Header.h" \ + --ignore "**/Runner/main.m" \ + --ignore "**/runner/main.cpp" \ + --ignore "**/runner/flutter_window.cpp" \ + --ignore "**/runner/resource.h" \ + --ignore "**/FlutterMultiDexApplication.java" \ + --ignore "**/GeneratedPluginRegistrant.swift" \ + --ignore "**/GeneratedPluginRegistrant.java" \ + --ignore "**/GeneratedPluginRegistrant.kotlin" \ + --ignore "**/GeneratedPluginRegistrant.m" \ + --ignore "**/GeneratedPluginRegistrant.h" \ + --ignore "**/Pods/**" \ + --ignore "**/flutter/generated_plugin_registrant.h" \ + --ignore "**/flutter/generated_plugin_registrant.cc" \ + --ignore "**/build/**" \ + . + description: Add a license header to all necessary files. + + check-license-header: + # If you add here another --ignore flag, add it also to + # "add-license-header". + run: | + addlicense -f header_template.txt \ + --check \ + --ignore "**/*.yml" \ + --ignore "**/*.yaml" \ + --ignore "**/*.xml" \ + --ignore "**/*.g.dart" \ + --ignore "**/*.sh" \ + --ignore "**/*.html" \ + --ignore "**/*.js" \ + --ignore "**/*.ts" \ + --ignore "**/*.g.h" \ + --ignore "**/*.g.m" \ + --ignore "**/*.rb" \ + --ignore "**/*.txt" \ + --ignore "**/*.cmake" \ + --ignore "**/doc/**" \ + --ignore "**/.dart_tool/**" \ + --ignore "**/Runner/MainFlutterWindow.swift" \ + --ignore "**/Runner/Runner-Bridging-Header.h" \ + --ignore "**/Runner/main.m" \ + --ignore "**/runner/main.cpp" \ + --ignore "**/runner/flutter_window.cpp" \ + --ignore "**/runner/resource.h" \ + --ignore "**/FlutterMultiDexApplication.java" \ + --ignore "**/GeneratedPluginRegistrant.swift" \ + --ignore "**/GeneratedPluginRegistrant.java" \ + --ignore "**/GeneratedPluginRegistrant.kotlin" \ + --ignore "**/GeneratedPluginRegistrant.m" \ + --ignore "**/GeneratedPluginRegistrant.h" \ + --ignore "**/Pods/**" \ + --ignore "**/flutter/generated_plugin_registrant.h" \ + --ignore "**/flutter/generated_plugin_registrant.cc" \ + --ignore "**/build/**" \ + . + description: Add a license header to all necessary files. diff --git a/melos_patapata.iml b/melos_patapata.iml new file mode 100644 index 0000000..9681559 --- /dev/null +++ b/melos_patapata.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/patapata_adjust/.gitignore b/packages/patapata_adjust/.gitignore new file mode 100644 index 0000000..96486fd --- /dev/null +++ b/packages/patapata_adjust/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/patapata_adjust/.metadata b/packages/patapata_adjust/.metadata new file mode 100644 index 0000000..e11c895 --- /dev/null +++ b/packages/patapata_adjust/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: 6928314d505d2bb4777be05e45d7808a5aa91d2a + channel: unknown + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 6928314d505d2bb4777be05e45d7808a5aa91d2a + base_revision: 6928314d505d2bb4777be05e45d7808a5aa91d2a + - platform: android + create_revision: 6928314d505d2bb4777be05e45d7808a5aa91d2a + base_revision: 6928314d505d2bb4777be05e45d7808a5aa91d2a + - platform: ios + create_revision: 6928314d505d2bb4777be05e45d7808a5aa91d2a + base_revision: 6928314d505d2bb4777be05e45d7808a5aa91d2a + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/patapata_adjust/CHANGELOG.md b/packages/patapata_adjust/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_adjust/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_adjust/LICENSE b/packages/patapata_adjust/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_adjust/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_adjust/README.md b/packages/patapata_adjust/README.md new file mode 100644 index 0000000..c526c1f --- /dev/null +++ b/packages/patapata_adjust/README.md @@ -0,0 +1,75 @@ +

+

Patapata - Adjust

+

+ Add support for Adjust to your Patapata app. +

+
+ +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Adjust](https://www.adjust.com/) to your Patapata app. +It will automatically send events to Adjust from Patapata's Analytics system. +It will also integrate with Patapata's User system and automatically send user information to Adjust by setting the Adjust SDK's `user_id` session callback parameter. + +This plugin will only start sending events to Adjust once Patapata's tracking permission has been processed at least once via `getApp().permissions.requestTracking()`. + +This plugin does not support any of the other features of Adjust, such as attribution or push notifications. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```yaml +dependencies: + patapata_adjust: + git: + url: git://github.com/gree/patapata.git + path: packages/patapata_adjust +``` + +2. Import the package + +```dart +import 'package:patapata_adjust/patapata_adjust.dart'; +``` + +3. Activate the plugin + +```dart +/// This Environment takes adjust configuration from environment variables. +/// Pass environment variables to your app using the `--dart-define` flag. +class Environment extends AdjustPluginEnvironment { + const Environment(); + + /// The app token issued when adding the app on Adjust's dashboard. + @override + String get adjustAppToken => const String.fromEnvironment('ADJUST_APP_TOKEN'); + + /// The environment for Adjust. Refer to the values in [AdjustEnvironment]. + @override + String get adjustEnvironment => const String.fromEnvironment('ADJUST_ENVIRONMENT'); + + /// The log level for Adjust. Refer to the values in [AdjustLogLevel]. + @override + String? get adjustLogLevel => const String.fromEnvironment('ADJUST_LOG_LEVEL'); +} + +void main() { + App( + environment: const Environment(), + plugins: [ + AdjustPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_adjust/LICENSE) \ No newline at end of file diff --git a/packages/patapata_adjust/analysis_options.yaml b/packages/patapata_adjust/analysis_options.yaml new file mode 100644 index 0000000..da00fd4 --- /dev/null +++ b/packages/patapata_adjust/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: diff --git a/packages/patapata_adjust/android/.gitignore b/packages/patapata_adjust/android/.gitignore new file mode 100644 index 0000000..161bdcd --- /dev/null +++ b/packages/patapata_adjust/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/packages/patapata_adjust/android/build.gradle b/packages/patapata_adjust/android/build.gradle new file mode 100644 index 0000000..d14268c --- /dev/null +++ b/packages/patapata_adjust/android/build.gradle @@ -0,0 +1,57 @@ +group 'dev.patapata.patapata_adjust' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 33 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 21 + } + + buildTypes { + release { + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' + implementation 'com.android.installreferrer:installreferrer:2.2' +} \ No newline at end of file diff --git a/packages/patapata_adjust/android/proguard-rules.pro b/packages/patapata_adjust/android/proguard-rules.pro new file mode 100644 index 0000000..fdf28a0 --- /dev/null +++ b/packages/patapata_adjust/android/proguard-rules.pro @@ -0,0 +1,12 @@ +-keep class com.adjust.sdk.** { *; } +-keep class com.google.android.gms.common.ConnectionResult { + int SUCCESS; +} +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient { + com.google.android.gms.ads.identifier.AdvertisingIdClient$Info getAdvertisingIdInfo(android.content.Context); +} +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient$Info { + java.lang.String getId(); + boolean isLimitAdTrackingEnabled(); +} +-keep public class com.android.installreferrer.** { *; } \ No newline at end of file diff --git a/packages/patapata_adjust/android/settings.gradle b/packages/patapata_adjust/android/settings.gradle new file mode 100644 index 0000000..7ee612a --- /dev/null +++ b/packages/patapata_adjust/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'patapata_adjust' diff --git a/packages/patapata_adjust/android/src/main/AndroidManifest.xml b/packages/patapata_adjust/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5730410 --- /dev/null +++ b/packages/patapata_adjust/android/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/packages/patapata_adjust/android/src/main/kotlin/dev/patapata/patapata_adjust/PatapataAdjustPlugin.kt b/packages/patapata_adjust/android/src/main/kotlin/dev/patapata/patapata_adjust/PatapataAdjustPlugin.kt new file mode 100644 index 0000000..0a5d84f --- /dev/null +++ b/packages/patapata_adjust/android/src/main/kotlin/dev/patapata/patapata_adjust/PatapataAdjustPlugin.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package dev.patapata.patapata_adjust + +import androidx.annotation.NonNull + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** PatapataAdjustPlugin */ +class PatapataAdjustPlugin: FlutterPlugin, MethodCallHandler { + private lateinit var channel : MethodChannel + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "patapata_adjust") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + result.notImplemented() + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } +} diff --git a/packages/patapata_adjust/dartdoc_options.yaml b/packages/patapata_adjust/dartdoc_options.yaml new file mode 100644 index 0000000..2a7e0c3 --- /dev/null +++ b/packages/patapata_adjust/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_adjust \ No newline at end of file diff --git a/packages/patapata_adjust/ios/.gitignore b/packages/patapata_adjust/ios/.gitignore new file mode 100644 index 0000000..0c88507 --- /dev/null +++ b/packages/patapata_adjust/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/packages/patapata_adjust/ios/Assets/.gitkeep b/packages/patapata_adjust/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/patapata_adjust/ios/Classes/PatapataAdjustPlugin.h b/packages/patapata_adjust/ios/Classes/PatapataAdjustPlugin.h new file mode 100644 index 0000000..d4ec012 --- /dev/null +++ b/packages/patapata_adjust/ios/Classes/PatapataAdjustPlugin.h @@ -0,0 +1,11 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface PatapataAdjustPlugin : NSObject +@end diff --git a/packages/patapata_adjust/ios/Classes/PatapataAdjustPlugin.m b/packages/patapata_adjust/ios/Classes/PatapataAdjustPlugin.m new file mode 100644 index 0000000..e952eda --- /dev/null +++ b/packages/patapata_adjust/ios/Classes/PatapataAdjustPlugin.m @@ -0,0 +1,20 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#import "PatapataAdjustPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "patapata_adjust-Swift.h" +#endif + +@implementation PatapataAdjustPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftPatapataAdjustPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/packages/patapata_adjust/ios/Classes/SwiftPatapataAdjustPlugin.swift b/packages/patapata_adjust/ios/Classes/SwiftPatapataAdjustPlugin.swift new file mode 100644 index 0000000..9c51127 --- /dev/null +++ b/packages/patapata_adjust/ios/Classes/SwiftPatapataAdjustPlugin.swift @@ -0,0 +1,19 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Flutter +import UIKit + +public class SwiftPatapataAdjustPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "patapata_adjust", binaryMessenger: registrar.messenger()) + let instance = SwiftPatapataAdjustPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result(nil) + } +} diff --git a/packages/patapata_adjust/ios/patapata_adjust.podspec b/packages/patapata_adjust/ios/patapata_adjust.podspec new file mode 100644 index 0000000..caff5c0 --- /dev/null +++ b/packages/patapata_adjust/ios/patapata_adjust.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint patapata_adjust.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'patapata_adjust' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '9.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/packages/patapata_adjust/lib/patapata_adjust.dart b/packages/patapata_adjust/lib/patapata_adjust.dart new file mode 100644 index 0000000..e7d1cf7 --- /dev/null +++ b/packages/patapata_adjust/lib/patapata_adjust.dart @@ -0,0 +1,164 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_adjust; + +import 'dart:async'; + +import 'package:adjust_sdk/adjust.dart'; +import 'package:adjust_sdk/adjust_config.dart'; +import 'package:adjust_sdk/adjust_event.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:patapata_core/patapata_core.dart'; + +/// Configuration for [AdjustPlugin]. +mixin AdjustPluginEnvironment { + /// The app token issued when adding the app on Adjust's dashboard. + String get adjustAppToken; + + /// The environment for Adjust. Refer to the values in [AdjustEnvironment]. + String get adjustEnvironment; + + /// The log level for Adjust. Refer to the values in [AdjustLogLevel]. + String? get adjustLogLevel; +} + +/// This is a plugin that provides Adjust functionality. +class AdjustPlugin extends Plugin + with WidgetsBindingObserver { + StreamSubscription? _eventsSubscription; + + /// Initializes the [AdjustPlugin]. + @override + FutureOr init(App app) async { + if (kIsWeb || app.environment is! AdjustPluginEnvironment) { + return false; + } + + await super.init(app); + + if (app.permissions.trackingRequested) { + _start(); + } else { + app.permissions.trackingStream.first.then((_) { + if (!disposed) { + _start(); + } + }); + } + + return true; + } + + AdjustPluginEnvironment get _environment => + app.environment as AdjustPluginEnvironment; + + void _start() { + final tConfig = AdjustConfig( + _environment.adjustAppToken, + AdjustEnvironment.values + .firstWhere((e) => e.name == _environment.adjustEnvironment), + ); + + if (_environment.adjustLogLevel?.isNotEmpty == true) { + tConfig.logLevel = AdjustLogLevel.values + .firstWhere((e) => e.name == _environment.adjustLogLevel); + } + + Adjust.start(tConfig); + + WidgetsBinding.instance.addObserver(this); + + _eventsSubscription = + app.analytics.eventsFor().listen(_onEvent); + app.user.addSynchronousChangeListener(_onUserChanged); + } + + @override + FutureOr dispose() async { + app.user.removeSynchronousChangeListener(_onUserChanged); + _eventsSubscription?.cancel(); + WidgetsBinding.instance.removeObserver(this); + if (await Adjust.isEnabled()) { + Adjust.setEnabled(false); + } + + return super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + Adjust.onResume(); + break; + case AppLifecycleState.paused: + Adjust.onPause(); + break; + default: + break; + } + } + + void _onEvent(AnalyticsEvent event) { + final tAdjustEvent = AdjustEvent(event.name); + + if (event is AnalyticsRevenueEvent) { + tAdjustEvent.setRevenue(event.revenue, event.currency ?? 'XXX'); + tAdjustEvent.transactionId = event.orderId; + } + + final tFlatData = event.flatData; + final tParameters = tFlatData != null + ? { + for (var i in tFlatData.entries) + i.key: Analytics.defaultMakeLoggableToNative(i.value), + } + : null; + + final tFinalParameters = { + if (event.navigationInteractionContextData != null) + for (var i in event.navigationInteractionContextData!.entries) + 'nic_${i.key}': Analytics.defaultMakeLoggableToNative(i.value), + }..addAll(tParameters ?? {}); + + for (var i in tFinalParameters.entries) { + final tValue = i.value?.toString() ?? ''; + + if (tValue.isNotEmpty) { + tAdjustEvent.addCallbackParameter(i.key, tValue); + } + } + + Adjust.trackEvent(tAdjustEvent); + } + + String? _lastId; + + FutureOr _onUserChanged(User user, UserChangeData changes) async { + final tId = changes.getIdFor>(); + final tProperties = changes.getPropertiesFor>(); + + if (tId != _lastId) { + _lastId = tId; + if (tId == null) { + Adjust.removeSessionCallbackParameter('user_id'); + } else { + Adjust.addSessionCallbackParameter('user_id', tId); + } + } + + for (var i in tProperties.entries) { + final tValue = i.value?.toString() ?? ''; + + if (tValue.isNotEmpty) { + Adjust.addSessionCallbackParameter(i.key, tValue); + } else { + Adjust.removeSessionCallbackParameter(i.key); + } + } + } +} diff --git a/packages/patapata_adjust/pubspec.yaml b/packages/patapata_adjust/pubspec.yaml new file mode 100644 index 0000000..fc00946 --- /dev/null +++ b/packages/patapata_adjust/pubspec.yaml @@ -0,0 +1,33 @@ +name: patapata_adjust +description: This package is a plugin for Patapata that adds support for Adjust to your Patapata app. +version: 1.0.0 +publish_to: none +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_adjust + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.1.5 + + patapata_core: ^1.0.0 + + adjust_sdk: ^4.33.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: + plugin: + platforms: + android: + package: dev.patapata.patapata_adjust + pluginClass: PatapataAdjustPlugin + ios: + pluginClass: PatapataAdjustPlugin diff --git a/packages/patapata_adjust/test/patapata_adjust_test.dart b/packages/patapata_adjust/test/patapata_adjust_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_adjust/test/patapata_adjust_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_apple_push_notifications/.gitignore b/packages/patapata_apple_push_notifications/.gitignore new file mode 100644 index 0000000..7c2eea5 --- /dev/null +++ b/packages/patapata_apple_push_notifications/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.dart_tool/ + +.packages +.pub/ + +build/ + +**/doc/api/ \ No newline at end of file diff --git a/packages/patapata_apple_push_notifications/.metadata b/packages/patapata_apple_push_notifications/.metadata new file mode 100644 index 0000000..eb5f3e0 --- /dev/null +++ b/packages/patapata_apple_push_notifications/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: plugin diff --git a/packages/patapata_apple_push_notifications/CHANGELOG.md b/packages/patapata_apple_push_notifications/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_apple_push_notifications/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_apple_push_notifications/LICENSE b/packages/patapata_apple_push_notifications/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_apple_push_notifications/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_apple_push_notifications/README.md b/packages/patapata_apple_push_notifications/README.md new file mode 100644 index 0000000..da6579d --- /dev/null +++ b/packages/patapata_apple_push_notifications/README.md @@ -0,0 +1,48 @@ +
+

Patapata - Apple Push Notifications

+

+ Add support for Apple Push Notifications to your Patapata app. +

+
+ +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Apple Push Notifications](https://developer.apple.com/documentation/usernotifications) to your Patapata app. +It will automatically integrate with Patapata's RemoteMessaging system and register for push notifications and provide access to APNs tokens. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_apple_push_notifications +``` + +2. Import the package + +```dart +import 'package:patapata_apple_push_notifications/patapata_apple_push_notifications.dart'; +``` + +3. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + ApplePushNotificationsPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_apple_push_notifications/LICENSE) \ No newline at end of file diff --git a/packages/patapata_apple_push_notifications/analysis_options.yaml b/packages/patapata_apple_push_notifications/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_apple_push_notifications/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_apple_push_notifications/dartdoc_options.yaml b/packages/patapata_apple_push_notifications/dartdoc_options.yaml new file mode 100644 index 0000000..61adcd2 --- /dev/null +++ b/packages/patapata_apple_push_notifications/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_apple_push_notifications \ No newline at end of file diff --git a/packages/patapata_apple_push_notifications/example/.gitignore b/packages/patapata_apple_push_notifications/example/.gitignore new file mode 100644 index 0000000..0fa6b67 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/patapata_apple_push_notifications/example/.metadata b/packages/patapata_apple_push_notifications/example/.metadata new file mode 100644 index 0000000..cb12308 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: app diff --git a/packages/patapata_apple_push_notifications/example/README.md b/packages/patapata_apple_push_notifications/example/README.md new file mode 100644 index 0000000..902faf2 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/README.md @@ -0,0 +1,16 @@ +# patapata_apple_push_notifications_example + +Demonstrates how to use the patapata_apple_push_notifications plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/patapata_apple_push_notifications/example/analysis_options.yaml b/packages/patapata_apple_push_notifications/example/analysis_options.yaml new file mode 100644 index 0000000..61b6c4d --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/patapata_apple_push_notifications/example/ios/.gitignore b/packages/patapata_apple_push_notifications/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/patapata_apple_push_notifications/example/ios/Flutter/AppFrameworkInfo.plist b/packages/patapata_apple_push_notifications/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..9625e10 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Flutter/Debug.xcconfig b/packages/patapata_apple_push_notifications/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/patapata_apple_push_notifications/example/ios/Flutter/Release.xcconfig b/packages/patapata_apple_push_notifications/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/patapata_apple_push_notifications/example/ios/Podfile b/packages/patapata_apple_push_notifications/example/ios/Podfile new file mode 100644 index 0000000..88359b2 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/patapata_apple_push_notifications/example/ios/Podfile.lock b/packages/patapata_apple_push_notifications/example/ios/Podfile.lock new file mode 100644 index 0000000..f0185a9 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Podfile.lock @@ -0,0 +1,60 @@ +PODS: + - connectivity_plus (0.0.1): + - Flutter + - ReachabilitySwift + - device_info_plus (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - patapata_apple_push_notifications (0.0.1): + - Flutter + - patapata_core + - patapata_core (0.0.1): + - Flutter + - ReachabilitySwift (5.0.0) + +DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - patapata_apple_push_notifications (from `.symlinks/plugins/patapata_apple_push_notifications/ios`) + - patapata_core (from `.symlinks/plugins/patapata_core/ios`) + +SPEC REPOS: + trunk: + - ReachabilitySwift + +EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + Flutter: + :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + patapata_apple_push_notifications: + :path: ".symlinks/plugins/patapata_apple_push_notifications/ios" + patapata_core: + :path: ".symlinks/plugins/patapata_core/ios" + +SPEC CHECKSUMS: + connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a + device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 + package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 + patapata_apple_push_notifications: d6a9d82ad04d4c1445dafa79f279065ccca3991e + patapata_core: ce5b4c8f74bd32c7c405f0d85d4cbbf25cb75da6 + ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 + +PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 + +COCOAPODS: 1.12.1 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.pbxproj b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4680558 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,542 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + E9DED2C107BF36CB89FCBCF8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB70CDFBC87CA64226D29D3E /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 084A4BDF948E44A12169D4DA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 6DD3E9A57A2B923D5ADA9AA0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8ADE9AC30F82395E53769F35 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FB70CDFBC87CA64226D29D3E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E9DED2C107BF36CB89FCBCF8 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4B45FCEAF3151B6A91C5833B /* Pods */ = { + isa = PBXGroup; + children = ( + 084A4BDF948E44A12169D4DA /* Pods-Runner.debug.xcconfig */, + 8ADE9AC30F82395E53769F35 /* Pods-Runner.release.xcconfig */, + 6DD3E9A57A2B923D5ADA9AA0 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 4B45FCEAF3151B6A91C5833B /* Pods */, + E082E5A20AEDD012371CEEA5 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + E082E5A20AEDD012371CEEA5 /* Frameworks */ = { + isa = PBXGroup; + children = ( + FB70CDFBC87CA64226D29D3E /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 1790C79EF3F20CB5FB989075 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 69595A6DE13247C482AB8BD6 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1790C79EF3F20CB5FB989075 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 69595A6DE13247C482AB8BD6 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.patapata.patapataApplePushNotificationsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.patapata.patapataApplePushNotificationsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.patapata.patapataApplePushNotificationsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..b52b2e6 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/AppDelegate.swift b/packages/patapata_apple_push_notifications/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..0f67170 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,18 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..f091b6b0bca859a3f474b03065bef75ba58a9e4c GIT binary patch literal 1588 zcmV-42Fv-0P)C1SqPt}wig>|5Crh^=oyX$BK<}M8eLU3e2hGT;=G|!_SP)7zNI6fqUMB=)y zRAZ>eDe#*r`yDAVgB_R*LB*MAc)8(b{g{9McCXW!lq7r(btRoB9!8B-#AI6JMb~YFBEvdsV)`mEQO^&#eRKx@b&x- z5lZm*!WfD8oCLzfHGz#u7sT0^VLMI1MqGxF^v+`4YYnVYgk*=kU?HsSz{v({E3lb9 z>+xILjBN)t6`=g~IBOelGQ(O990@BfXf(DRI5I$qN$0Gkz-FSc$3a+2fX$AedL4u{ z4V+5Ong(9LiGcIKW?_352sR;LtDPmPJXI{YtT=O8=76o9;*n%_m|xo!i>7$IrZ-{l z-x3`7M}qzHsPV@$v#>H-TpjDh2UE$9g6sysUREDy_R(a)>=eHw-WAyfIN z*qb!_hW>G)Tu8nSw9yn#3wFMiLcfc4pY0ek1}8(NqkBR@t4{~oC>ryc-h_ByH(Cg5 z>ao-}771+xE3um9lWAY1FeQFxowa1(!J(;Jg*wrg!=6FdRX+t_<%z&d&?|Bn){>zm zZQj(aA_HeBY&OC^jj*)N`8fa^ePOU72VpInJoI1?`ty#lvlNzs(&MZX+R%2xS~5Kh zX*|AU4QE#~SgPzOXe9>tRj>hjU@c1k5Y_mW*Jp3fI;)1&g3j|zDgC+}2Q_v%YfDax z!?umcN^n}KYQ|a$Lr+51Nf9dkkYFSjZZjkma$0KOj+;aQ&721~t7QUKx61J3(P4P1 zstI~7-wOACnWP4=8oGOwz%vNDqD8w&Q`qcNGGrbbf&0s9L0De{4{mRS?o0MU+nR_! zrvshUau0G^DeMhM_v{5BuLjb#Hh@r23lDAk8oF(C+P0rsBpv85EP>4CVMx#04MOfG z;P%vktHcXwTj~+IE(~px)3*MY77e}p#|c>TD?sMatC0Tu4iKKJ0(X8jxQY*gYtxsC z(zYC$g|@+I+kY;dg_dE>scBf&bP1Nc@Hz<3R)V`=AGkc;8CXqdi=B4l2k|g;2%#m& z*jfX^%b!A8#bI!j9-0Fi0bOXl(-c^AB9|nQaE`*)Hw+o&jS9@7&Gov#HbD~#d{twV zXd^Tr^mWLfFh$@Dr$e;PBEz4(-2q1FF0}c;~B5sA}+Q>TOoP+t>wf)V9Iy=5ruQa;z)y zI9C9*oUga6=hxw6QasLPnee@3^Rr*M{CdaL5=R41nLs(AHk_=Y+A9$2&H(B7!_pURs&8aNw7?`&Z&xY_Ye z)~D5Bog^td-^QbUtkTirdyK^mTHAOuptDflut!#^lnKqU md>ggs(5nOWAqO?umG&QVYK#ibz}*4>0000U6E9hRK9^#O7(mu>ETqrXGsduA8$)?`v2seloOCza43C{NQ$$gAOH**MCn0Q?+L7dl7qnbRdqZ8LSVp1ItDxhxD?t@5_yHg6A8yI zC*%Wgg22K|8E#!~cTNYR~@Y9KepMPrrB8cABapAFa=`H+UGhkXUZV1GnwR1*lPyZ;*K(i~2gp|@bzp8}og7e*#% zEnr|^CWdVV!-4*Y_7rFvlww2Ze+>j*!Z!pQ?2l->4q#nqRu9`ELo6RMS5=br47g_X zRw}P9a7RRYQ%2Vsd0Me{_(EggTnuN6j=-?uFS6j^u69elMypu?t>op*wBx<=Wx8?( ztpe^(fwM6jJX7M-l*k3kEpWOl_Vk3@(_w4oc}4YF4|Rt=2V^XU?#Yz`8(e?aZ@#li0n*=g^qOcVpd-Wbok=@b#Yw zqn8u9a)z>l(1kEaPYZ6hwubN6i<8QHgsu0oE) ziJ(p;Wxm>sf!K+cw>R-(^Y2_bahB+&KI9y^);#0qt}t-$C|Bo71lHi{_+lg#f%RFy z0um=e3$K3i6K{U_4K!EX?F&rExl^W|G8Z8;`5z-k}OGNZ0#WVb$WCpQu-_YsiqKP?BB# vzVHS-CTUF4Ozn5G+mq_~Qqto~ahA+K`|lyv3(-e}00000NkvXXu0mjfd`9t{ literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d0ef06e7edb86cdfe0d15b4b0d98334a86163658 GIT binary patch literal 1716 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f9ed8f5cee1c98386d13b17e89f719e83555b2 GIT binary patch literal 1895 zcmV-t2blPYP)FQtfgmafE#=YDCq`qUBt#QpG%*H6QHY765~R=q zZ6iudfM}q!Pz#~9JgOi8QJ|DSu?1-*(kSi1K4#~5?#|rh?sS)(-JQqX*}ciXJ56_H zdw=^s_srbAdqxlvGyrgGet#6T7_|j;95sL%MtM;q86vOxKM$f#puR)Bjv9Zvz9-di zXOTSsZkM83)E9PYBXC<$6(|>lNLVBb&&6y{NByFCp%6+^ALR@NCTse_wqvNmSWI-m z!$%KlHFH2omF!>#%1l3LTZg(s7eof$7*xB)ZQ0h?ejh?Ta9fDv59+u#MokW+1t8Zb zgHv%K(u9G^Lv`lh#f3<6!JVTL3(dCpxHbnbA;kKqQyd1~^Xe0VIaYBSWm6nsr;dFj z4;G-RyL?cYgsN1{L4ZFFNa;8)Rv0fM0C(~Tkit94 zz#~A)59?QjD&pAPSEQ)p8gP|DS{ng)j=2ux)_EzzJ773GmQ_Cic%3JJhC0t2cx>|v zJcVusIB!%F90{+}8hG3QU4KNeKmK%T>mN57NnCZ^56=0?&3@!j>a>B43pi{!u z7JyDj7`6d)qVp^R=%j>UIY6f+3`+qzIc!Y_=+uN^3BYV|o+$vGo-j-Wm<10%A=(Yk^beI{t%ld@yhKjq0iNjqN4XMGgQtbKubPM$JWBz}YA65k%dm*awtC^+f;a-x4+ddbH^7iDWGg&N0n#MW{kA|=8iMUiFYvMoDY@sPC#t$55gn6ykUTPAr`a@!(;np824>2xJthS z*ZdmT`g5-`BuJs`0LVhz+D9NNa3<=6m;cQLaF?tCv8)zcRSh66*Z|vXhG@$I%U~2l z?`Q zykI#*+rQ=z6Jm=Bui-SfpDYLA=|vzGE(dYm=OC8XM&MDo7ux4UF1~0J1+i%aCUpRe zt3L_uNyQ*cE(38Uy03H%I*)*Bh=Lb^Xj3?I^Hnbeq72(EOK^Y93CNp*uAA{5Lc=ky zx=~RKa4{iTm{_>_vSCm?$Ej=i6@=m%@VvAITnigVg{&@!7CDgs908761meDK5azA} z4?=NOH|PdvabgJ&fW2{Mo$Q0CcD8Qc84%{JPYt5EiG{MdLIAeX%T=D7NIP4%Hw}p9 zg)==!2Lbp#j{u_}hMiao9=!VSyx0gHbeCS`;q&vzeq|fs`y&^X-lso(Ls@-706qmA z7u*T5PMo_w3{se1t2`zWeO^hOvTsohG_;>J0wVqVe+n)AbQCx)yh9;w+J6?NF5Lmo zecS@ieAKL8%bVd@+-KT{yI|S}O>pYckUFs;ry9Ow$CD@ztz5K-*D$^{i(_1llhSh^ zEkL$}tsQt5>QA^;QgjgIfBDmcOgi5YDyu?t6vSnbp=1+@6D& z5MJ}B8q;bRlVoxasyhcUF1+)o`&3r0colr}QJ3hcSdLu;9;td>kf@Tcn<@9sIx&=m z;AD;SCh95=&p;$r{Xz3iWCO^MX83AGJ(yH&eTXgv|0=34#-&WAmw{)U7OU9!Wz^!7 zZ%jZFi@JR;>Mhi7S>V7wQ176|FdW2m?&`qa(ScO^CFPR80HucLHOTy%5s*HR0^8)i h0WYBP*#0Ks^FNSabJA*5${_#%002ovPDHLkV1oKhTl@e3 literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..75b2d164a5a98e212cca15ea7bf2ab5de5108680 GIT binary patch literal 3831 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..c4df70d39da7941ef3f6dcb7f06a192d8dcb308d GIT binary patch literal 1888 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 iW8Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/patapata_apple_push_notifications/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Base.lproj/Main.storyboard b/packages/patapata_apple_push_notifications/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Info.plist b/packages/patapata_apple_push_notifications/example/ios/Runner/Info.plist new file mode 100644 index 0000000..93950f9 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + patapata_apple_push_notifications_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/packages/patapata_apple_push_notifications/example/ios/Runner/Runner-Bridging-Header.h b/packages/patapata_apple_push_notifications/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/patapata_apple_push_notifications/example/lib/main.dart b/packages/patapata_apple_push_notifications/example/lib/main.dart new file mode 100644 index 0000000..3d2827c --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/lib/main.dart @@ -0,0 +1,38 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({Key? key}) : super(key: key); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Plugin example app'), + ), + body: const Center( + child: Text('TODO'), + ), + ), + ); + } +} diff --git a/packages/patapata_apple_push_notifications/example/pubspec.yaml b/packages/patapata_apple_push_notifications/example/pubspec.yaml new file mode 100644 index 0000000..b64751e --- /dev/null +++ b/packages/patapata_apple_push_notifications/example/pubspec.yaml @@ -0,0 +1,84 @@ +name: patapata_apple_push_notifications_example +description: Demonstrates how to use the patapata_apple_push_notifications plugin. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ">=2.12.0 <3.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + patapata_apple_push_notifications: + # When depending on this package from a real application you should use: + # patapata_apple_push_notifications: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^1.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/patapata_apple_push_notifications/ios/.gitignore b/packages/patapata_apple_push_notifications/ios/.gitignore new file mode 100644 index 0000000..0c88507 --- /dev/null +++ b/packages/patapata_apple_push_notifications/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/packages/patapata_apple_push_notifications/ios/Assets/.gitkeep b/packages/patapata_apple_push_notifications/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/patapata_apple_push_notifications/ios/Classes/PatapataApplePushNotificationsPlugin.h b/packages/patapata_apple_push_notifications/ios/Classes/PatapataApplePushNotificationsPlugin.h new file mode 100644 index 0000000..b6cee95 --- /dev/null +++ b/packages/patapata_apple_push_notifications/ios/Classes/PatapataApplePushNotificationsPlugin.h @@ -0,0 +1,11 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface PatapataApplePushNotificationsPlugin : NSObject +@end diff --git a/packages/patapata_apple_push_notifications/ios/Classes/PatapataApplePushNotificationsPlugin.m b/packages/patapata_apple_push_notifications/ios/Classes/PatapataApplePushNotificationsPlugin.m new file mode 100644 index 0000000..e9d1bef --- /dev/null +++ b/packages/patapata_apple_push_notifications/ios/Classes/PatapataApplePushNotificationsPlugin.m @@ -0,0 +1,20 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#import "PatapataApplePushNotificationsPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "patapata_apple_push_notifications-Swift.h" +#endif + +@implementation PatapataApplePushNotificationsPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftPatapataApplePushNotificationsPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/packages/patapata_apple_push_notifications/ios/Classes/SwiftPatapataApplePushNotificationsPlugin.swift b/packages/patapata_apple_push_notifications/ios/Classes/SwiftPatapataApplePushNotificationsPlugin.swift new file mode 100644 index 0000000..75b2223 --- /dev/null +++ b/packages/patapata_apple_push_notifications/ios/Classes/SwiftPatapataApplePushNotificationsPlugin.swift @@ -0,0 +1,175 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Flutter +import UIKit +import patapata_core + +public class SwiftPatapataApplePushNotificationsPlugin: NSObject, FlutterPlugin, UNUserNotificationCenterDelegate, PatapataPlugin { + + public static func register(with registrar: FlutterPluginRegistrar) { + let tInstance = SwiftPatapataApplePushNotificationsPlugin(registrar: registrar) + registrar.registerPatapata(plugin: tInstance) + } + + fileprivate let mChannel: FlutterMethodChannel + fileprivate var mTokenData: String? + fileprivate var mInitialNotification: [String: Any]? + + fileprivate var mEnabled = false + + init(registrar: FlutterPluginRegistrar) { + mChannel = FlutterMethodChannel(name: "dev.patapata.patapata_apple_push_notifications", binaryMessenger: registrar.messenger()) + + super.init() + + registrar.addMethodCallDelegate(self, channel: mChannel) + registrar.addApplicationDelegate(self) + } + + public var patapataName = "dev.patapata.patapata_apple_push_notifications" + + public func patapataEnable() { + guard !mEnabled else { + return + } + + mEnabled = true + + UIApplication.shared.applicationIconBadgeNumber = 0 + UNUserNotificationCenter.current().delegate = self + UIApplication.shared.registerForRemoteNotifications() + + if (mTokenData != nil) { + notifyUpdatedAPNsToken() + } + } + + public func patapataDisable() { + guard mEnabled else { + return + } + + mEnabled = false + mTokenData = nil + UNUserNotificationCenter.current().delegate = nil + UIApplication.shared.unregisterForRemoteNotifications() + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getToken": + result(mTokenData) + break + case "requestPermission": + requestPermission(result: result) + break + case "getInitialNotification": + getInitialNotification(result: result) + break + default: + result(FlutterMethodNotImplemented) + break + } + } + + private func requestPermission(result: @escaping FlutterResult) { + guard mEnabled else { + result(false) + return + } + + UNUserNotificationCenter.current().requestAuthorization(options: [ + .alert, .badge, .sound + ]) { + succeeded, error in + result(succeeded) + } + } + + private func getInitialNotification(result: @escaping FlutterResult) { + let tInitialNotification = mInitialNotification + mInitialNotification = nil + result(tInitialNotification) + } + + private func dictionaryFromNotificationResponse(notification: UNNotificationResponse) -> [String : Any] { + let tDateFormatter = ISO8601DateFormatter() + let tNotification = notification.notification + + return [ + "actionIdentifier": notification.actionIdentifier, + "notification": [ + "date": tDateFormatter.string(from: tNotification.date), + "request": [ + "identifier": tNotification.request.identifier, + "content": [ + "title": tNotification.request.content.title, + "body": tNotification.request.content.body, + "userInfo": tNotification.request.content.userInfo + ] + ] + ] + ] + } + + private func getTokenString(deviceToken: Data) -> String { + return deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + } + + private func notifyUpdatedAPNsToken() { + guard mEnabled else { + return + } + + mChannel.invokeMethod("updateAPNsToken", arguments: mTokenData) + } + + public func applicationDidBecomeActive(_ application: UIApplication) { + guard mEnabled else { + return + } + + UIApplication.shared.applicationIconBadgeNumber = 0 + } + + public func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + mTokenData = getTokenString(deviceToken: deviceToken) + notifyUpdatedAPNsToken() + } + + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + + public func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { + guard mEnabled else { + return false + } + + if UIApplication.shared.applicationState == .background { + // No need to handle BG data + completionHandler(.newData) + } else { + mChannel.invokeMethod("didReceiveRemoteNotification", arguments: userInfo) + completionHandler(.noData) + } + + return true + } + + public func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.alert]) + } + + public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + mInitialNotification = dictionaryFromNotificationResponse(notification: response) + UIApplication.shared.applicationIconBadgeNumber = 0 + mChannel.invokeMethod("didReceiveNotificationResponse", arguments: mInitialNotification) + completionHandler() + } +} diff --git a/packages/patapata_apple_push_notifications/ios/patapata_apple_push_notifications.podspec b/packages/patapata_apple_push_notifications/ios/patapata_apple_push_notifications.podspec new file mode 100644 index 0000000..c2458f1 --- /dev/null +++ b/packages/patapata_apple_push_notifications/ios/patapata_apple_push_notifications.podspec @@ -0,0 +1,25 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint patapata_apple_push_notifications.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'patapata_apple_push_notifications' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '10.0' + + s.dependency 'patapata_core' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/packages/patapata_apple_push_notifications/lib/patapata_apple_push_notifications.dart b/packages/patapata_apple_push_notifications/lib/patapata_apple_push_notifications.dart new file mode 100644 index 0000000..7bb960d --- /dev/null +++ b/packages/patapata_apple_push_notifications/lib/patapata_apple_push_notifications.dart @@ -0,0 +1,187 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_apple_push_notifications; + +import 'package:flutter/services.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +final _logger = Logger('patapata.ApplePushNotificationsPlugin'); +const _channel = + MethodChannel('dev.patapata.patapata_apple_push_notifications'); + +/// A plugin that provides Apple push notification functionality to Patapata. +class ApplePushNotificationsPlugin extends Plugin { + final _messagesController = StreamController.broadcast(); + final _tokensController = StreamController.broadcast(); + + @override + String get name => 'dev.patapata.patapata_apple_push_notifications'; + + /// Initializes the [ApplePushNotificationsPlugin]. + @override + FutureOr init(App app) async { + await super.init(app); + + _channel.setMethodCallHandler(_handleMethodCall); + + return true; + } + + @override + FutureOr dispose() async { + await super.dispose(); + } + + @override + RemoteMessaging createRemoteMessaging() => + ApplePushNotificationsRemoteMessaging(this); + + RemoteMessage _createRemoteMessage(Map response) { + final tNotification = Map.castFrom( + response['notification'] as Map); + final tRequest = Map.castFrom( + tNotification['request'] as Map); + final tContent = Map.castFrom( + tRequest['content'] as Map); + + return RemoteMessage( + messageId: tRequest['identifier'], + data: Map.castFrom( + tContent['userInfo'] as Map), + notification: (tContent['title'] as String?)?.isNotEmpty == true || + (tContent['body'] as String?)?.isNotEmpty == true + ? RemoteMessageNotification( + title: tContent['title'] as String?, + body: tContent['body'] as String?, + ) + : null, + ); + } + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'updateAPNsToken': + final tToken = call.arguments as String?; + _logger.info('updateAPNsToken:$tToken'); + _tokensController.add(tToken); + break; + case 'didReceiveRemoteNotification': + final tUserInfo = Map.castFrom( + call.arguments as Map); + _logger.info('didReceiveRemoteNotification:$tUserInfo'); + // _messagesController.add(RemoteMessage( + // messageId: '', + // data: tUserInfo, + // notification: RemoteMessageNotification(), + // )); + break; + case 'didReceiveNotificationResponse': + final tResponse = Map.castFrom( + call.arguments as Map); + _logger.info('didReceiveNotificationResponse:$tResponse'); + + _messagesController.add(_createRemoteMessage(tResponse)); + break; + default: + break; + } + } + + /// A [Stream] that can be listened to for monitoring changes to [RemoteMessage]. + Stream get messages => _messagesController.stream; + + /// A [Stream] that can be used to monitor changes to the token. + Stream get tokenStream => _tokensController.stream; + + /// Returns a Future for the initial Apple notification RemoteMessage. + Future getInitialNotification() async { + final tNotificationResponse = await _channel + .invokeMapMethod('getInitialNotification'); + + return tNotificationResponse != null + ? _createRemoteMessage(tNotificationResponse) + : null; + } + + /// Returns a Future for the token required for the application to use RemoteMessage. + Future getToken() { + return _channel.invokeMethod('getToken'); + } +} + +/// A class for monitoring remote messages of Apple push notifications. +class ApplePushNotificationsRemoteMessaging extends RemoteMessaging { + final ApplePushNotificationsPlugin _instance; + + StreamSubscription? _onMessageSubscription; + StreamSubscription? _onTokensSubscription; + + final _messagesController = StreamController.broadcast(); + final _tokensController = StreamController.broadcast(); + + /// Creates a [ApplePushNotificationsRemoteMessaging]. + ApplePushNotificationsRemoteMessaging(this._instance); + + /// Initializes the [ApplePushNotificationsRemoteMessaging]. + @override + Future init(App app) async { + await super.init(app); + + _onMessageSubscription = _instance.messages.listen(_onMessage); + _onTokensSubscription = _instance.tokenStream.listen(_onToken); + } + + @override + void dispose() { + _onMessageSubscription?.cancel(); + _onMessageSubscription = null; + _onTokensSubscription?.cancel(); + _onTokensSubscription = null; + super.dispose(); + } + + void _onMessage(RemoteMessage message) async { + _logger.info( + '_onMessage:{messageId:${message.messageId} channel:${message.channel}, data:${message.data}}'); + + _messagesController.add(message); + } + + void _onToken(String? token) { + _logger.info('_onToken:$token'); + + _tokensController.add(token); + } + + @override + Future getInitialMessage() async { + return _instance.getInitialNotification(); + } + + @override + Stream get messages => _messagesController.stream; + + @override + Stream get tokens => _tokensController.stream; + + @override + Future getToken() async { + return _instance.getToken(); + } + + @override + // ignore: must_call_super + FutureOr listenChannel(String channel) async { + return false; + } + + @override + // ignore: must_call_super + FutureOr ignoreChannel(String channel) async { + return false; + } +} diff --git a/packages/patapata_apple_push_notifications/melos_patapata_apple_push_notifications.iml b/packages/patapata_apple_push_notifications/melos_patapata_apple_push_notifications.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/patapata_apple_push_notifications/melos_patapata_apple_push_notifications.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_apple_push_notifications/pubspec.yaml b/packages/patapata_apple_push_notifications/pubspec.yaml new file mode 100644 index 0000000..3f57829 --- /dev/null +++ b/packages/patapata_apple_push_notifications/pubspec.yaml @@ -0,0 +1,26 @@ +name: patapata_apple_push_notifications +description: This package is a plugin for Patapata that adds support for Apple Push Notifications to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_apple_push_notifications + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: + plugin: + platforms: + ios: + pluginClass: PatapataApplePushNotificationsPlugin diff --git a/packages/patapata_apple_push_notifications/test/patapata_apple_push_notifications_test.dart b/packages/patapata_apple_push_notifications/test/patapata_apple_push_notifications_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_apple_push_notifications/test/patapata_apple_push_notifications_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_apps_flyer/.gitignore b/packages/patapata_apps_flyer/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_apps_flyer/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_apps_flyer/.metadata b/packages/patapata_apps_flyer/.metadata new file mode 100644 index 0000000..4311ca2 --- /dev/null +++ b/packages/patapata_apps_flyer/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: package diff --git a/packages/patapata_apps_flyer/CHANGELOG.md b/packages/patapata_apps_flyer/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_apps_flyer/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_apps_flyer/LICENSE b/packages/patapata_apps_flyer/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_apps_flyer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_apps_flyer/README.md b/packages/patapata_apps_flyer/README.md new file mode 100644 index 0000000..679a39f --- /dev/null +++ b/packages/patapata_apps_flyer/README.md @@ -0,0 +1,70 @@ +
+

Patapata - AppsFlyer

+

+ Add support for AppsFlyer to your Patapata app. +

+
+ +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [AppsFlyer](https://www.appsflyer.com/) to your Patapata app. +It currently only supports setting the user ID for AppsFlyer via the `setCustomerUserId` method, as well as registering for conversion data callbacks. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```yaml +dependencies: + patapata_apps_flyer: + git: + url: git://github.com/gree/patapata.git + path: packages/patapata_apps_flyer +``` + +2. Import the package + +```dart +import 'package:patapata_apps_flyer/patapata_apps_flyer.dart'; +``` + +3. Activate the plugin + +```dart +/// This Environment takes AppsFlyer configuration from environment variables. +/// Pass environment variables to your app using the `--dart-define` flag. +class Environment extends AppsFlyerPluginEnvironment { + const Environment(); + + /// The AppsFlyer devKey. + @override + String get appsFlyerDevKey => const String.fromEnvironment('APPS_FLYER_DEV_KEY'); + + /// AppsFlyer's iOS `appId`. + @override + String get appsFlyerAppIdIOS => const String.fromEnvironment('APPS_FLYER_APP_ID_IOS'); + + /// AppsFlyer's Android `appId`. + @override + String get appsFlyerAppIdAndroid => const String.fromEnvironment('APPS_FLYER_APP_ID_ANDROID'); +} + +void main() { + App( + environment: const Environment(), + plugins: [ + AppsFlyerPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_apps_flyer/LICENSE) \ No newline at end of file diff --git a/packages/patapata_apps_flyer/analysis_options.yaml b/packages/patapata_apps_flyer/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_apps_flyer/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_apps_flyer/dartdoc_options.yaml b/packages/patapata_apps_flyer/dartdoc_options.yaml new file mode 100644 index 0000000..0d35966 --- /dev/null +++ b/packages/patapata_apps_flyer/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_apps_flyer \ No newline at end of file diff --git a/packages/patapata_apps_flyer/ios/.gitignore b/packages/patapata_apps_flyer/ios/.gitignore new file mode 100644 index 0000000..0c88507 --- /dev/null +++ b/packages/patapata_apps_flyer/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/packages/patapata_apps_flyer/ios/Assets/.gitkeep b/packages/patapata_apps_flyer/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/patapata_apps_flyer/ios/Classes/PatapataAppsFlyerPlugin.h b/packages/patapata_apps_flyer/ios/Classes/PatapataAppsFlyerPlugin.h new file mode 100644 index 0000000..1b8779c --- /dev/null +++ b/packages/patapata_apps_flyer/ios/Classes/PatapataAppsFlyerPlugin.h @@ -0,0 +1,11 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface PatapataAppsFlyerPlugin : NSObject +@end diff --git a/packages/patapata_apps_flyer/ios/Classes/PatapataAppsFlyerPlugin.m b/packages/patapata_apps_flyer/ios/Classes/PatapataAppsFlyerPlugin.m new file mode 100644 index 0000000..05c3773 --- /dev/null +++ b/packages/patapata_apps_flyer/ios/Classes/PatapataAppsFlyerPlugin.m @@ -0,0 +1,20 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#import "PatapataAppsFlyerPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "patapata_apps_flyer-Swift.h" +#endif + +@implementation PatapataAppsFlyerPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftPatapataAppsFlyerPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/packages/patapata_apps_flyer/ios/Classes/SwiftPatapataAppsFlyerPlugin.swift b/packages/patapata_apps_flyer/ios/Classes/SwiftPatapataAppsFlyerPlugin.swift new file mode 100644 index 0000000..9bc6817 --- /dev/null +++ b/packages/patapata_apps_flyer/ios/Classes/SwiftPatapataAppsFlyerPlugin.swift @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Flutter + +public class SwiftPatapataAppsFlyerPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let tInstance = SwiftPatapataAppsFlyerPlugin() + } +} diff --git a/packages/patapata_apps_flyer/ios/patapata_apps_flyer.podspec b/packages/patapata_apps_flyer/ios/patapata_apps_flyer.podspec new file mode 100644 index 0000000..478b82c --- /dev/null +++ b/packages/patapata_apps_flyer/ios/patapata_apps_flyer.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint patapata_apps_flyer.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'patapata_apps_flyer' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '8.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/packages/patapata_apps_flyer/lib/patapata_apps_flyer.dart b/packages/patapata_apps_flyer/lib/patapata_apps_flyer.dart new file mode 100644 index 0000000..51b2ff3 --- /dev/null +++ b/packages/patapata_apps_flyer/lib/patapata_apps_flyer.dart @@ -0,0 +1,97 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_apps_flyer; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:appsflyer_sdk/appsflyer_sdk.dart'; + +/// Configuration for [AppsflyerSdk]. +mixin AppsFlyerPluginEnvironment { + /// AppsFlyer's devKey. + String get appsFlyerDevKey; + + /// AppsFlyer's iOS `appId`. + String get appsFlyerAppIdIOS; + + /// AppsFlyer's Android `appId`. + String get appsFlyerAppIdAndroid; +} + +/// A plugin that provides functionality for AppsFlyer in Patapata. +class AppsFlyerPlugin extends Plugin { + late AppsflyerSdk _sdk; + + /// A reference to the [AppsflyerSdk] instance. + AppsflyerSdk get sdk => _sdk; + + /// Initializes the [AppsFlyerPlugin]. + @override + FutureOr init(App app) async { + if (app.environment is! AppsFlyerPluginEnvironment) { + return false; + } + + final tDevKey = _environment.appsFlyerDevKey; + final tAppId = defaultTargetPlatform == TargetPlatform.iOS + ? _environment.appsFlyerAppIdIOS + : _environment.appsFlyerAppIdAndroid; + + if (tDevKey.isEmpty || tAppId.isEmpty) { + return false; + } + + await super.init(app); + + var tIsDebug = false; + + assert(() { + tIsDebug = true; + return true; + }()); + + _sdk = AppsflyerSdk(AppsFlyerOptions( + afDevKey: tDevKey, + appId: tAppId, + showDebug: tIsDebug, + timeToWaitForATTUserAuthorization: 60.0, + )); + + app.user.addSynchronousChangeListener(_onUserChanged); + if (app.permissions.trackingRequested) { + await initSdk(); + } else { + app.permissions.trackingStream.first.then((_) => initSdk); + } + + return true; + } + + AppsFlyerPluginEnvironment get _environment => + app.environment as AppsFlyerPluginEnvironment; + + @override + FutureOr dispose() { + app.user.removeSynchronousChangeListener(_onUserChanged); + + return super.dispose(); + } + + /// Initializes the [AppsflyerSdk]. + Future initSdk() async { + await _sdk.initSdk( + registerConversionDataCallback: true, + registerOnAppOpenAttributionCallback: false, + registerOnDeepLinkingCallback: false, + ); + } + + FutureOr _onUserChanged(User user, UserChangeData changes) { + _sdk.setCustomerUserId(changes.id ?? ''); + } +} diff --git a/packages/patapata_apps_flyer/pubspec.yaml b/packages/patapata_apps_flyer/pubspec.yaml new file mode 100644 index 0000000..cff6cb1 --- /dev/null +++ b/packages/patapata_apps_flyer/pubspec.yaml @@ -0,0 +1,29 @@ +name: patapata_apps_flyer +description: This package is a plugin for Patapata that adds support for AppsFlyer to your Patapata app. +version: 1.0.0 +publish_to: none +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_apps_flyer + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + appsflyer_sdk: ^6.11.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: + plugin: + platforms: + ios: + pluginClass: PatapataAppsFlyerPlugin diff --git a/packages/patapata_apps_flyer/test/patapata_apps_flyer_test.dart b/packages/patapata_apps_flyer/test/patapata_apps_flyer_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_apps_flyer/test/patapata_apps_flyer_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_core/.github/assets/logo_pata2_horizontal.png b/packages/patapata_core/.github/assets/logo_pata2_horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..c1b341959849d5abda8790099b0f48a2158dac21 GIT binary patch literal 9695 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 cW5f#{{M +

Patapata

+ +
+ +
+ +

+ A collection of best-practices and tools for building applications quickly and reliably. +

+ +

+ Project Homepage +

+ +

+ + GitHub Workflow Status (branch) + + + Pub Popularity + + + Maintained with Melos + + + License + + + + +

+ + + +--- + +## Table of Contents + - [About](#about) + - [Supported Platforms](#supported-platforms) + - [Getting started](#getting-started) + - [Bootstrap](#bootstrap) + - [Usage](#usage) + - [Environment](#environment) + - [App](#app) + - [App startup flow](#app-startup-flow) + - [Startup Sequence](#startup-sequence) + - [User](#user) + - [Standard App](#standard-app) + - [Internationalization and localization (I18n and L10n)](#internationalization-and-localization-(i18n-and-l10n)) + - [Logging and error handling](#logging-and-error-handling) + - [Notifications](#notifications) + - [Utilities](#utilities) + - [Finite State Machine](#finite-state-machine) + - [Sequential Work Queue](#sequential-work-queue) + - [Fake DateTime](#fake-datetime) + - [Provider Model](#provider-model) + - [Screen Layout](#screen-layout) + - [Platform Dialog](#platform-dialog) + - [Testing your application](#testing-your-application) + - [Testing in the IDE](#testing-in-the-ide) + - [Contributing](#contributing) + - [License](#license) + +## About + +Patapata is a framework built on Flutter for creating applications of production quality quickly and reliably. +It provides a collection of best-practices built directly in to the various APIs so you can build apps that are consistent, stable, and performant. + +Patapata Core is the core framework that provides the basic building blocks for your application. +You will always depend on this package in your application to use Patapata. +In addition, you can use any of the plugins that Patapata provides to add additional functionality to your application. +See the [homepage](https://github.com/gree/patapata) for details. + +### Supported Platforms + +We try to support the newest version of Flutter and will not purposely keep support for older versions if something is deprecated on the Flutter side. + +The Patapata team believes that it is important to keep up to date with the latest version of Flutter as in our expierence with real world applications, old versions of Flutter have trouble supporting the newer versions of Android and especially iOS. + +Currently, we support Flutter 3.13.0 and above, with a minimum Dart version of 3.0.0 up to 4.0.0. + +We officially support Android, iOS fully, and best effort for Web and MacOS. +Windows and Linux are currently not supported. + +## Getting started + +To just get the standard Patapata experience and have an app up and running, execute the following in a terminal: + +```bash +flutter create my_app +cd my_app +flutter pub add patapata_core +dart run patapata_core:bootstrap +``` + +Note that this will change the minimum Android SDK version to 21 and the minimum iOS version to 12.0. + +You should be able to run your application! + +### Bootstrap + +As in the example above, you can use the `bootstrap` command to quickly get started with Patapata. + +This command will: +- Generate an `Environment` file for you with `I18nEnvironment` and `LogEnvironment` setup by default +- Generate a `main.dart` file that will create a `Standard App` for you with default settings for almost all of Patapata's features, and a place to add your own `Provider` models to be accessable throughout your application. +- Generate a `Startup Sequence` that has a splash screen, a fake agreement page, and finally a home page. +- Generate a default error page for when your app encounters a fatal error. +- Generate an error class that supports the `PatapataException` system for your app in it's own namespace. +- Enable [Flutter's deep link system](https://docs.flutter.dev/ui/navigation/deep-linking) that `Standard App` will use for external links to your application +- Setup the `L10n` system (localization) for your application with default yaml files for English (by default) + +## Usage + +Patapata has many different systems that all work together to make an application that is easy to maintain and extend. +Each system of Patapata has [documentation](https://pub.dev/documentation/patapata_core/latest/) that you can read to learn more about it. + +Patapata strives to make not just development, but maintence of your application easy and as automatic as possible. + +Especially if you use the `bootstrap` command setup, you have automatic logging and reporting of errors to any supported 3rd party service, automatic remote configuration of your application, and localization support ready to go (just add text to your yaml files). You have a splash screen and start up sequence with deep linking out of the box, error handling out of the box, standard features that basically all applications use ready to go (such as package information, device information, local configuration, network information, etc). You also have an analytics system that automatically is sending routing events, page data changing events, lifecycle events, and more. + +Developer tools such a Finite State Machine system, a work queue system, a concept of a User, multiple screen size auto layout and more are all available to you. + +Tools such as a fake DateTime system exist to make QA testing and backend testing easier as well. + +Patapata provides a few must have packages that can be easily accessed without manually importing by importing `package:patapata_core/patapata_core_libs.dart`. + +These packages are: +- [provider](https://pub.dev/packages/provider) +- [logging](https://pub.dev/packages/logging) +- [timezone](https://pub.dev/packages/timezone) +- [visibility_detector](https://pub.dev/packages/visibility_detector) +- [flutter_local_notifications](https://pub.dev/packages/flutter_local_notifications) + +### Environment + +The `Environment` class is responsible for setting up the environment for your application. +This class is something you create yourself, and pass to `App` when you create it. + +The concept is that your `Environment` class will mixin multiple `Environment` mixins that are provided by Patapata and plugins. Each of these mixins will setup a different part of your application's environment. + +All of the `Environment` mixins following a naming convention of `NameEnvironment`, where `Name` is the name of the system that it is setting up. + +In general you should try very hard to make your `Environment` class `const` so that it can be used in a `const` context. +This is important so that any tree shaking that Flutter does will remove any code that is not used in your application as well as for performance reasons. + +Also, if you use one of the [String.fromEnvironment](https://api.flutter.dev/flutter/dart-core/String/String.fromEnvironment.html) methods, if you don't use a `const` certain platforms will not function correctly. +The `String.fromEnvironment` and friends can be used to pass in environment variables to your application at build time via the `--dart-define` flag. + +Here is a simple example of an `Environment` class: + +```dart +class Environment + with + I18nEnvironment, + LogEnvironment, + SentryEnvironment { + /// A base URL for your API. + final String apiBaseUrl; + + /// An API key for your API. + final String apiKey; + + /// Set's what locales your application supports. + @override + final List supportedL10ns = const [Locale('en')]; + + /// Set's where your application will look for localization files. + @override + final List l10nPaths = const [ + 'l10n', + ]; + + /// The default log level. + @override + final int logLevel; + + /// Whether or not to print logs to the console. + @override + final bool printLog; + + /// The Sentry DSN to use if for example you are using Sentry. + @override + final String sentryDSN; + + /// A function that will be called to setup Sentry. + @override + final FutureOr Function(SentryFlutterOptions)? sentryOptions = null; + + const MyEnvironment({ + this.apiBaseUrl = const String.fromEnvironment('API_BASE_URL'), + this.apiKey = const String.fromEnvironment('API_KEY'), + this.logLevel = + const int.fromEnvironment('LOG_LEVEL', defaultValue: -kPataInHex), + this.printLog = + const bool.fromEnvironment('PRINT_LOG', defaultValue: kDebugMode), + this.sentryDSN = const String.fromEnvironment('SENTRY_DSN'), + }); +} + +void main() async { + App( + environment: const Environment(), + .... + ) + .run(); +} +``` + +### App + +The [App](https://pub.dev/documentation/patapata_core/latest/patapata_core/App-class.html) class is the main entry point for your application. +It is responsible for setting up all of Patapata's systems and plugins, and then running your application. + +Your entire application will be run inside a special `Zone` that Patapata manages. +When in this `Zone`, you can access the current `App` via the [getApp](https://pub.dev/documentation/patapata_core/latest/patapata_core/getApp.html) function. + +```dart +getApp().environment.apiBaseUrl; +``` + +Your application will also be the child of several `Provider` widgets that are provided by Patapata that allow you to listen to changes via [context.watch](https://pub.dev/documentation/provider/latest/provider/WatchContext/watch.html), [context.select](https://pub.dev/documentation/provider/latest/provider/SelectContext/select.html) and friends. + +```dart +Widget build(BuildContext context) { + final tOnline = context.select( + (v) => v.connectivity != NetworkConnectivity.none + ); + + if (tOnline) { + return const Text('Online'); + } else { + return const Text('Offline'); + } +} +``` + +`App` also exposes `Provider`s for: +- The `App` itself +- The genericly typed `Environment` version of your `App` as `App` +- The `Environment` +- [User](https://pub.dev/documentation/patapata_core/latest/patapata_core/User-class.html) + - A class to manage the concept of a 'user' in your application +- [RemoteConfig](https://pub.dev/documentation/patapata_core/latest/patapata_core/RemoteConfig-class.html) + - A class to access remote configuration data for your application +- [LocalConfig](https://pub.dev/documentation/patapata_core/latest/patapata_core/LocalConfig-class.html) + - A class to access locally stored simple key value data for your application +- [RemoteMessaging](https://pub.dev/documentation/patapata_core/latest/patapata_core/RemoteMessaging-class.html) + - A class to access remote messaging data for your application, such as push notifications. +- [Analytics](https://pub.dev/documentation/patapata_core/latest/patapata_core/Analytics-class.html) + - A class to collect and send analytics data for your application +- The global [AnalyticsContext](https://pub.dev/documentation/patapata_core/latest/patapata_core/AnalyticsContext-class.html) +- [NetworkInformation](https://pub.dev/documentation/patapata_core/latest/patapata_core/NetworkInformation-class.html) as a [StreamProvider](https://pub.dev/documentation/provider/latest/provider/StreamProvider-class.html) +- [PackageInfoPlugin](https://pub.dev/documentation/patapata_core/latest/patapata_core/PackageInfoPlugin-class.html) + - Quick access to all meta information about your application +- [DeviceInfoPlugin](https://pub.dev/documentation/patapata_core/latest/patapata_core/DeviceInfoPlugin-class.html) + - Quick access to all information about the device your application is running on + +Some of which are listenable (and therefore watch and selectable). + +#### App startup flow + +The `App` class goes through a series of steps to setup your application that have specific rules for when things are initialized and when you are allowed to access the various systems of Patapata. + +In general, as a developer who is not customizing with `Plugin`s, you should not have to worry about this and can just use the `App` class as is. + +`App` goes through the steps are defined in [AppStage](https://pub.dev/documentation/patapata_core/latest/patapata_core/AppStage.html). + +1. `setup` - The first stage where the `App` hasn't done any operations and `run` hasn't been executed yet. Nothing is initialized at this point and attempts to access any API except for `the add/remove/hasPlugin` methods will result in undefined behavior. Usually an exception will be thrown. +2. `bootstrap` - This stage is entered upon execution of `run`. Immediately after entering this stage, the following are executed synchronously: + 1. Flutter's services are initialized made useable + 2. The special `Zone` that Patapata manages is created an entered + 3. The [Log](https://pub.dev/documentation/patapata_core/latest/patapata_core/Log-class.html) system becomes useable + 4. Flutter's [ErrorWidget.builder](https://api.flutter.dev/flutter/widgets/ErrorWidget/builder.html) is set to [nonDebugErrorWidgetBuilder](https://pub.dev/documentation/patapata_core/latest/patapata_core/App/nonDebugErrorWidgetBuilder.html) + 5. The callback passed to `run` will be executed in a non-asynchronous manner so you are guaranteed that you are still on the same dart task as when `main` was executed During this stage +3. `initializingPlugins` - The default `Plugin`s and `Plugins`s passed to the `App` are initialized. First, `Plugin`s that have [requireRemoteConfig](https://pub.dev/documentation/patapata_core/latest/patapata_core/Plugin/requireRemoteConfig.html) set to `false` are initialized, allowing for `RemoteConfig` `Plugin`s to be be available and allow remote disabling of `Plugin`s via `RemoteConfig`. If any `Plugin`s fail to initialize, [onInitFailure](https://pub.dev/documentation/patapata_core/latest/patapata_core/App/onInitFailure.html) is called or if null, the error is printed to the console and your app fails to start. +4. `setupRemoteConfig` - The `RemoteConfig` system is initialized and is useable after this point. Patapata will attempt to fetch the newest remote config data with a 2 second timeout. A timeout will not generate an error and will just delay the start of your application by those 2 seconds. +5. `initializingPluginsWithRemoteConfig` - The remaining non-initialized `Plugin`s are initialized and remotely removed `Plugin`s are removed (and are never initialized). If any `Plugin`s fail to initialize, [onInitFailure](https://pub.dev/documentation/patapata_core/latest/patapata_core/App/onInitFailure.html) is called or if null, the error is printed to the console and your app fails to start. +6. `running` - At this stage, all of `Patapata`'s systems are useable. [createAppWidget](https://pub.dev/documentation/patapata_core/latest/patapata_core/App/createAppWidget.html) is wrapped with all the `Provider`s set up by `App`, and wrapped with the `Analytics` system's pointer listener for tracking all pointer events. + +If at any point during the above sequence an unhandled error is thrown, `App` will remove the native splash screen, and attempt to report the error to the logging system. + +### Startup Sequence + +The [StartupSequence](https://pub.dev/documentation/patapata_core/latest/patapata_core/StartupSequence-class.html) class can help you create a startup flow for your application. + +You should almost always use this though it is not required. + +A Startup Sequence would be a list of actions your app _always_ performs on startup. +You can provide conditions for each action to be executed, and provide a flow until the processing of the initial actual 'home' page of your application is ready to be shown (or a deep linked page). + +The most general use case for this is to show a splash screen, then show a terms of service page, then show a login page, then show the home page. + +### User + +The [User](https://pub.dev/documentation/patapata_core/latest/patapata_core/User-class.html) class is a class that represents a user of your application. +It is a `ChangeNotifier` so you can listen to changes to the user's data. + +The `User` class is a generic class that you can use to represent any type of user you want. + +You can extend this class to make a unique user class for your application, and let Patapata know about it by passing the [userFactory](https://pub.dev/documentation/patapata_core/latest/patapata_core/App/userFactory.html) parameter to `App`. + +The `User` is used by the `Analytics` system to track user data, and `Plugin`s can use it to track user data and to provide user specific functionality, log in and out functionality, etc automatically. + +For example, the `patapata_firebase_analytics`, `patapata_firebase_crashlytics`, and `patapata_sentry` plugins all use the `User` class to assign properties to the user in their respective systems. + +### Standard App + +Standard App is an optional, but highly recommended `Plugin` that is enabled by default that you can use to add the concept of a 'page' to your application, and add full support for running an production quality application with very little code. + +To use it, you pass either the [StandardMaterialApp](https://pub.dev/documentation/patapata_core/latest/patapata_widgets/StandardMaterialApp-class.html) or [StandardCupertinoApp](https://pub.dev/documentation/patapata_core/latest/patapata_widgets/StandardMaterialApp-class.html) to your `App`'s `createAppWidget` parameter. + +From there, you define your standard Flutter `MaterialApp` or `CupertinoApp` settings as normal as well as a list of 'pages' that exist in your application. + +Each of these pages is a `StatefulWidget` that extends [StandardPage](https://pub.dev/documentation/patapata_core/latest/patapata_widgets/StandardPage-class.html). + +```dart +void main() { + App( + createAppWidget: (context, app) => StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + // This is the landing page for the application. + StandardPageFactory( + create: (_) => HomePage(), + links: { + // An empty deep link means this page will be shown when the app is opened externally without directly specifying a page. + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + // Home will _always_ exist in the navigation stack with this setting. + groupRoot: true, + ), + // This is just an example of another simple page definition. + StandardPageFactory( + create: (_) => SettingsPage(), + links: { + r'settings': (match, uri) {}, + }, + linkGenerator: (pageData) => 'settings', + ), + // This is an example of a page that has a page data object. + StandardPageFactory( + create: (_) => SearchPage(), + links: { + // When 'search' as a deep link is opened, this page will be shown, + // mapping the uri data to the required page data object. + r'search': (match, uri) { + return SearchPageData( + query: uri.queryParameters['q'] ?? '', + reverseSort: uri.queryParameters['r'] == '1', + ); + }, + }, + // This regenerates the deep link for this page based off the current page data. + linkGenerator: (pageData) => Uri( + path: 'search', + queryParameters: { + 'q': pageData.query, + 'r': pageData.reverseSort ? '1' : '0', + }, + ).toString(), + ), + ], + theme: ThemeData( + primarySwatch: Colors.blue, + ), + ), + ) + .run(); +} + +/// This is the simplest page definition. +class HomePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'home.title')), + ), + body: Center( + child: Text(l(context, 'home.body')), + ), + ); + } +} + +class SearchPageData { + final String query; + final bool reverseSort; + + const SearchPageData({ + required this.query, + this.reverseSort = false, + }); +} + +/// This is a page that has a page data object. +class SearchPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'search.title')), + ), + body: Center( + // You can access the pageData anywhere. + child: Text(pageData.query), + ), + ); + } +} +``` + +You can also define a page that returns a result to whoever if opened it. + +```dart +void checkWhatUserWants() { + if (await context.goWithResult) { + // The user said yes. + } else { + // The user said no. + } +} + +/// This time use [StandardPageWithResult] instead of [StandardPage]. +/// The final generic type is the return type. +class AskUserPage extends StandardPageWithResult { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + children: [ + Text(l(context, 'ask.body')), + ElevatedButton( + onPressed: () { + // The traditional, not type safe way + Navigator.pop(context, true); + }, + child: Text(l(context, 'ask.yes')), + ), + ElevatedButton( + onPressed: () { + // The new type safe way. + // Set the result any time you want. + pageResult = false; + + // Then remove the current route later. + context.removeRoute(); + // or Navigator.pop(context); + }, + child: Text(l(context, 'ask.no')), + ), + ], + ), + ), + ); + } +} + +void main() { + App( + createAppWidget: (context, app) => StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + // Use this factory for result pages. + StandardPageWithResultFactory( + create: (_) => AskUserPage(), + ), + ], + ), + ) + .run(); +} +``` + +Quite often, for example, in a search page, you can change the original page data object and want to update the deep link to the current page. `StandardPage` has a `pageData` property that you can set to update the deep link. The `StandardPage` itself is also set in a provider so you can access it from anywhere in your page's widget tree. This allows child widgets to access the page data as well. + +```dart +class SearchPageData { + final String query; + final bool reverseSort; + + const SearchPageData({ + required this.query, + this.reverseSort = false, + }); +} + +final _logger = Logger('SearchPage'); + +class SearchPage extends StandardPage { + @override + void onPageData() { + // If you want to do something every time the page data changes, + // you can override this method. + _logger.info('pageData changed: $pageData'); + } + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'search.title')), + ), + body: Column( + children: [ + ElevatedButton( + onPressed: () { + // This will update the deep link to the current page. + // As well as fire off related analytics events. + setState(() { + pageData = SearchPageData( + query: pageData.query, + reverseSort: !pageData.reverseSort, + ); + }); + }, + child: Text(l(context, 'search.toggleSort')), + ), + Text('${pageData.query}: ${pageData.reverseSort}'), + ], + ), + ); + } +} +``` + +A `StandardPage` automatically sets up several analytics features related to page navigation and lifecycle events, as well as when page data changes. Watch the flow of analytics events in your debug log to see what is happening. + +You may often want to customize your entire application but need access to `MaterialApp`'s various features such as `Theme`, `MediaQuery` and more. You may also want to provide a global user interface that wraps all of your pages. + +To do all of this, use the [routableBuilder](https://pub.dev/documentation/patapata_core/latest/patapata_widgets/StandardMaterialApp/routableBuilder.html) parameter of `MaterialStandardApp` and `CupertinoStandardApp`. + +```dart +void main() { + App( + createAppWidget: (context, app) => StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + // your pages here + ], + routableBuilder: (context, child) { + return Stack( + children: [ + child, + Positioned( + bottom: 0, + right: 0, + width: 100, + child: ElevatedButton( + onPressed: () { + // You can use this context to navigate. + // However, Navigator.of will not work. + // The reason is [child] is the [Navigator]. + context.go(null); + }, + child: Text(l(context, 'mypage')), + ) + ), + ], + ); + }, + ), + ) + .run(); +} +``` + +`StandardPage` also keeps track of your page's active and inactive lifecycle events. +A page is 'active' when it is the top page in the navigation stack. A page is 'inactive' when it is not the top page in the navigation stack. + +```dart +class MyPage extends StandardPage { + @override + void onActive(bool first) { + // This will be called when the page becomes active. + // [first] will be true on the first time a page becomes active. + // Usually that is when the page is first created. + super.onActive(); + } + + @override + void onInactive() { + // This will be called when the page becomes inactive. + super.onInactive(); + } + + @override + void onRefocus() { + // This will be called when the page is already active and is navigated to again. + } +} +``` + +To enable 100% automatic handling of deep links at startup, be sure to use [StartupSequence](https://pub.dev/documentation/patapata_core/latest/patapata_core/StartupSequence-class.html) and enable Flutter's default deep link handling. +All of this is done if you use the `bootstrap` command. + +## Internationalization and localization (I18n and L10n) + +Patapata has a built in system for internationalization and localization. +It will first of all automatially initialize the [timezone](https://pub.dev/packages/timezone) package so you can use all of it's features out of the box. + +As a small feature, Patapata provides an extension to `DateTime` that provides a few common methods for DateTime string formatting commonly used with APIs. +- [toUTCIso8601StringNoMSUS](https://pub.dev/documentation/patapata_core/latest/patapata_core/DateDateTimeExtension/toUTCIso8601StringNoMSUS.html) +- [asDateString](https://pub.dev/documentation/patapata_core/latest/patapata_core/DateDateTimeExtension/asDateString.html) + +The localization system of Patapata is a core feature that every app should heavily be using. +It is based on writing yaml files that contain your localized strings as a tree of key value pairs. + +It supports Flutter's [MessageFormat](https://api.flutter.dev/flutter/message_format/MessageFormat-class.html) system, which is a subset of the ICU MessageFormat syntax that can handle plurals and select statements (for genders, etc), as well just simple string interpolation. + +The yaml files can be hot reloaded and so development is easy and fast. + +You use the system with the [l](https://pub.dev/documentation/patapata_core/latest/patapata_core/l.html) function. + +```dart +Text(l(context, 'page1.title')); +``` + +Languages will change automatically when the user changes the language of their device. + +## Logging and error handling + +Logging is accomplished with dart's standard [logging](https://pub.dev/packages/logging) package. + +Patapata hooks in to the root Logger and provides a 'reporting' system you and plugins can use to filter, transform, and send to 3rd party services. + +```dart +getApp().log.addFilter((report) { + // Only log things that have errors attached. + return switch (report.error) { + null => null, + _ => report, + }; +}); +``` + +```dart +getApp().log.reports.listen((report) { + // Do something with this report. +}); +``` + +Logging to console is automatically disabled in release builds, and Patapata disables debugPrint and print for release builds automatically as well for security reasons. + +Patapata also provides a system for handling errors in your application. + +If you make all of your errors inherit from [PatapataException](https://pub.dev/documentation/patapata_core/latest/patapata_core/PatapataException-class.html), the logging system and various other systems can automatically perform actions when errors occur. They also will use the L10n system to localize errors. +Errors are also namespaced to that each section of your code or each plugin can have it's own error namespace and therefore error code to show to the user for quick user support. + +## Notifications + +Patapata currently uses [flutter_local_notifications](https://pub.dev/packages/flutter_local_notifications) for local notifications. + +Patapata will automatically set up the package for you with decent default settings. +To use it in your application, `import package:patapata_core/patapata_core_libs.dart` and follow the documentation for [flutter_local_notifications](https://pub.dev/packages/flutter_local_notifications) to display notifications. You should be able to jump right in to executing the API to show a notification. + +If you use `StandardApp`, notifications will automatically be wired to open deep links in your application or, if you want to handle a custom notification yourself with `StandardApp`, you can add a [link handler](https://pub.dev/documentation/patapata_core/latest/patapata_widgets/StandardAppPlugin/addLinkHandler.html). + +If you do not use `StandardApp`, you can use the [NotificationPlugin's notification stream](https://pub.dev/documentation/patapata_core/latest/patapata_core/NotificationsPlugin/notifications.html) directly. + +```dart +getApp().getPlugin()?.notifications.listen((notification) { + // Do something with the notification. +}); +``` + +## Utilities + +There are a few utilities that Patapata provides that you can use in your application. + +### Finite State Machine + +Patapata has a [LogicStateMachine](https://pub.dev/documentation/patapata_core/latest/patapata_core/LogicStateMachine-class.html) class that you can use to create a finite state machine for your application. [StartupSequence](https://pub.dev/documentation/patapata_core/latest/patapata_core/StartupSequence-class.html) uses this class to manage it's state. + +### Sequential Work Queue + +Patapata has a [SequentialWorkQueue](https://pub.dev/documentation/patapata_core/latest/patapata_core/SequentialWorkQueue-class.html) class that you can use to create a work queue that will execute jobs in order, one at a time. + +It optionally supports adding jobs that can cancel previous jobs, including stopping the execution of actual dart code with callbacks to allow for cleanup. + +### Fake DateTime + +Patapata exposes a global getter called [now](https://pub.dev/documentation/patapata_core/latest/patapata_core/now.html) that you can use to get the current date and time. + +The definition of 'the current date and time' can be changed by using [setFakeNow](https://pub.dev/documentation/patapata_core/latest/patapata_core/setFakeNow.html), with options to persist the fake time across app restarts as well as having `now` to return the elapsed time since the fake time was set. + +This is useful for testing and debugging, as well as syncing your application to a 'server time'. + +We recommend using `now` instead of `DateTime.now()` for all uses of the current date and time in your application, except for when something relies on the user's actual local device time, or a time that must be accurate to an external source. + +### Provider Model + +Often while designing a model based system in dart, the 'model' needs to execute code asynchroniously to complete a modification to itself. For example, a model that needs to fetch data from a server. + +In these cases, it is very possible for that same model to get another request to update itself again with newer values. + +Sometimes, you want to cancel the previous request and only use the latest request. Sometimes, you want to queue up the requests and execute them in order. While other times, you want to make the second request invalid and not execute it at all. + +Setting up a system like this is error prone and time consuming. + +Patapata provides a class called [ProviderModel](https://pub.dev/documentation/patapata_core/latest/patapata_core/ProviderModel-class.html) that you can use to easily create a model that can handle all of these cases. + +It supports concepts such as 'variables' that are managed and 'transactions' that can be either queued, cancelled, or invalidated. We call these 'transactions' 'batches'. + +```dart +class MyModel extends ProviderModel { + final _key = ProviderLockKey('forUpdating'); + + late final _myVariable = createVariable('defaultValueHere'); + String get myVariable => _myVariable.unsafeValue; + + late final _myCounter = createVariable(0); + int get myCounter => _myCounter.unsafeValue; + + /// Update [myVariable] and [myCounter] 'atomically'. + /// If this is called in succession before the previous + /// execution finishes, the second execution will cancel + /// the first, and only the last value will be committed. + Future updateMyVariable(String newValue) { + return lock( + _key, + (batch) async { + // Increment the counter. + // At this point, we haven't commited the results + // to the model, so any access to [myCounter] will still + // not be updated. + batch.set(_myCounter, batch.get(_myCounter) + 1); + + if (newValue.isEmpty) { + batch.cancel(); + + return; + } + + // We pretend an API will update remote data. + // If it fails, we cancel the batch, and once again + // nothing will be updated locally. + // [blockOverride] is used to prevent the batch from + // being cancelled while in the middle of API execution + // because we don't want the API classes' Zone execution + // stop and not cleanup. + // If this batch was cancelled or overriden it will + // cancel after this API call finishes. + if (!await batch.blockOverride(() => api.updateMyVariable(newValue))) { + batch.cancel(); + + return; + } + + // Set the variable and commit the result to the model. + // On commit, anything listening to this + // variable will be updated. + batch.set(_myVariable, newValue); + batch.commit(); + }, + overridable: true, + override: true, + ); + } + + /// Update [myVariable] and [myCounter] 'atomically'. + /// This one will not cancel the previous execution, + /// but will instead fail immediately if another + /// execution is in progress. + bool updateMyVariableOnlyLocally(String newValue) { + try { + // If another lock is in progress, this will throw immediately. + final tBatch = begin(_key); + + tBatch.set(_myCounter, tBatch.get(_myCounter) + 1); + tBatch.set(_myVariable, newValue); + tBatch.commit(); + + return true; + } catch (e) { + return false; + } + } +} + +Widget build(BuildContext context) { + return Provider( + create: (_) => MyModel(), + child: Selector( + selector: (context, model) => model.myVariable, + builder: (context, myVariable, child) { + return Column( + children: [ + Text(myVariable), + ElevatedButton( + onPressed: () { + context.read().updateMyVariable('new value'); + }, + child: const Text('Update'), + ), + ], + ); + }, + ), + ); +} +``` + +As you can see, this is fairly complex to setup, but once you have it setup, it is both easy to use, and can be very powerful. It is designed to cover that 1% case where users do strange things to your application, and prevent your application from crashing or entering an invalid state or other odd issues. + +### Screen Layout + +Patapata has a helper layout Widget that has the capability to layout all child widgets as if the screen was a certain size. After layout, the child widgets will be scaled to fit the screen. + +This is very useful for applications that want to have a single design for screen sizes based off breakpoints, and want to scale the design instead of reflow the design for different screen sizes. + +See [ScreenLayout](https://pub.dev/documentation/patapata_core/latest/patapata_widgets/ScreenLayout-class.html) for more information. + +### Platform Dialog + +Patapata has a [PlatformDialog](https://pub.dev/documentation/patapata_core/latest/patapata_widgets/PlatformDialog-class.html) widget that you can use to show a platform specific dialog. + +```dart +PlatformDialog.show( + context: context, + title: l(context, 'dialog.title'), + message: l(context, 'dialog.message'), + actions: [ + PlatformDialogAction( + result: () => true, + text: l(context, 'dialog.yes'), + isDefault: true, + ), + PlatformDialogAction( + result: () => false, + text: l(context, 'dialog.no'), + ), + ], +); +``` + +## Testing your application + +Patapata's plugins and features use native APIs and rely on running on real devices to generally work. +In a testing environment, you can't use the native APIs, so you need to mock them. +Patapata itself will automatically mock itself if you set the environment variable `IS_TEST` to true. + +```bash +flutter test --dart-define=IS_TEST=true +``` + +Once you have set this environment variable, you can use a few tools in your own tests to quickly and easily leaverage Patapata's features in your tests. +Typically, you would write a test as follows: + +```dart +void main() { + // These two lines are required to mock the native APIs + // and _must_ be set before any other code is run. + TestWidgetsFlutterBinding.ensureInitialized(); + testSetMockMethodCallHandler = TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger.setMockMethodCallHandler; + + // This StreamHandler is necessary when mocking streams from native APIs, and its responses should be handled + // in the onListen and onCancel methods of a class that inherits from the MockStreamHandler class. + testSetMockStreamHandler = (channel, handler) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + channel, + _MockStreamHandler(handler), + ); + }; + + testWidgets('My App should run', (WidgetTester tester) async { + final tApp = createApp( + appWidget: StandardMaterialApp(....), + startupSequence: StartupSequence(....), + plugins: [....], + ); + + // It is important to await this, otherwise [App] will not be able + // to initialize correctly. + await tApp.run(); + + // Always run your tests in a [runProcess] block. + // Flutter's test system runs code in a different Zone + // than what you app runs in, and functions like [getApp] or the logging system + // require to be run in a Zone that Patapata is managing. + await tApp.runProcess(() async { + // Always pumpAndSettle to let Patapata finish initializing. + await tester.pumpAndSettle(); + + // Write your tests here. + }); + + // You must call this before executing the next test. + tApp.dispose(); + }); +} + +// This class is necessary when preparing a mock stream handler. +class _MockStreamHandler extends MockStreamHandler { + _MockStreamHandler(this.handler); + + final TestMockStreamHandler? handler; + + @override + void onCancel(Object? arguments) { + // This is where you can handle the onCancel event. + } + + @override + void onListen(Object? arguments, MockStreamHandlerEventSink events) { + // This is where you can handle the onListen event. + } +} +``` + +If you are a `Plugin` developer and want to mock your own plugin, you can do so by overriding `setMockMethodCallHandler` in your plugin. +Currently supported by `App`, `Plugin` and `Config`. + +Example: TestPlugin +```dart +class TestPlugin extends Plugin { + @override + @visibleForTesting + void setMockMethodCallHandler() { + testSetMockMethodCallHandler( + const MethodChannel('com.mock.testplugin'), + (methodCall) async { + methodCallLogs.add(methodCall); + switch (methodCall.method) { + case 'flight': + debugPrint('patapata'); + default: + break; + } + return null; + }, + ); + } +} +``` + +Note that there are no hard dependencies on the Flutter test package in this code. + +Furthermore, when testing events on the custom plugin side, you can conduct tests using the mock event channel `setMockStreamHandler`. +```dart +class TestStreamHandlerPlugin extends Plugin { + @override + @visibleForTesting + void setMockStreamHandler() { + testSetMockStreamHandler( + const EventChannel('com.mock.testplugin'), + _TestMockStreamHandler(), + ); + } +} + +class _TestMockStreamHandler extends TestMockStreamHandler { + @override + void onCancel(Object? arguments) {} + + @override + void onListen(Object? arguments, TestMockStreamHandlerEventSink events) { + events.success('sucess event'); + } +} + +``` + +This can also be written using the inline function `TestMockStreamHandler.inline`. + +Example: TestStreamHandlerInlinePlugin +```dart +class TestStreamHandlerInlinePlugin extends Plugin { + @override + @visibleForTesting + void setMockStreamHandler() { + testSetMockStreamHandler( + const EventChannel('com.mock.testplugin'), + TestMockStreamHandler.inline( + onListen: (_, events) { + events.success('sucess event'); + }, + ), + ); + } +} +``` + +### Testing in the IDE + +If you run tests from an IDE, you can set the environment variable in the IDE's settings. +Example: for .vscode/settings.json +```json +{ + "dart.flutterTestAdditionalArgs": ["--dart-define=IS_TEST=true"] +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_core/LICENSE) \ No newline at end of file diff --git a/packages/patapata_core/analysis_options.yaml b/packages/patapata_core/analysis_options.yaml new file mode 100644 index 0000000..da00fd4 --- /dev/null +++ b/packages/patapata_core/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: diff --git a/packages/patapata_core/android/.gitignore b/packages/patapata_core/android/.gitignore new file mode 100644 index 0000000..f6dca24 --- /dev/null +++ b/packages/patapata_core/android/.gitignore @@ -0,0 +1,13 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +*~ +.externalNativeBuild +.DS_Store +**/ndkHelperBin +**/.cxx \ No newline at end of file diff --git a/packages/patapata_core/android/build.gradle b/packages/patapata_core/android/build.gradle new file mode 100644 index 0000000..a6cb73a --- /dev/null +++ b/packages/patapata_core/android/build.gradle @@ -0,0 +1,76 @@ +group 'dev.patapata.patapata_core' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.8.10" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.dokka' + +android { + compileSdkVersion 33 + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 21 + } + + buildTypes { + release { + // Enables code shrinking, obfuscation, and optimization for only + // your project's release build type. + minifyEnabled true + + // Includes the default ProGuard rules files that are packaged with + // the Android Gradle plugin. To learn more, go to the section about + // R8 configuration files. + proguardFiles getDefaultProguardFile( + 'proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } +} + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "androidx.datastore:datastore-preferences:1.0.0" + + // https://github.com/flutter/flutter/issues/110658#issuecomment-1282045767 + // Crash fix for desugaring which is required by local notifications plugin + implementation "androidx.window:window:1.0.0-rc01" + implementation "androidx.window:window-java:1.0.0-rc01" +} diff --git a/packages/patapata_core/android/gradle.properties b/packages/patapata_core/android/gradle.properties new file mode 100644 index 0000000..82dfa36 --- /dev/null +++ b/packages/patapata_core/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.builder.sdkDownload=true diff --git a/packages/patapata_core/android/gradle/wrapper/gradle-wrapper.properties b/packages/patapata_core/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..111c25e --- /dev/null +++ b/packages/patapata_core/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip diff --git a/packages/patapata_core/android/proguard-rules.pro b/packages/patapata_core/android/proguard-rules.pro new file mode 100644 index 0000000..ea6dd79 --- /dev/null +++ b/packages/patapata_core/android/proguard-rules.pro @@ -0,0 +1,32 @@ +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +##---------------End: proguard configuration for Gson ---------- \ No newline at end of file diff --git a/packages/patapata_core/android/settings.gradle b/packages/patapata_core/android/settings.gradle new file mode 100644 index 0000000..a068de2 --- /dev/null +++ b/packages/patapata_core/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'patapata_core' diff --git a/packages/patapata_core/android/src/main/AndroidManifest.xml b/packages/patapata_core/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2c05dc --- /dev/null +++ b/packages/patapata_core/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/Error.kt b/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/Error.kt new file mode 100644 index 0000000..e6f0aa2 --- /dev/null +++ b/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/Error.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package dev.patapata.patapata_core + +fun Throwable.toPatapataMap() : Map { + return mapOf( + "type" to javaClass.name, + "message" to message, + "stackTrace" to stackTrace.map { it.toString() }, + "cause" to cause?.run { toPatapataMap() } + ) +} + +enum class Error { + PPE000, + PPENLC000 +} diff --git a/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/NativeLocalConfig.kt b/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/NativeLocalConfig.kt new file mode 100644 index 0000000..b21b798 --- /dev/null +++ b/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/NativeLocalConfig.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package dev.patapata.patapata_core + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +private val Context.nativeLocalConfigStore: DataStore by preferencesDataStore(name = "dev.patapata.native_local_config") + +class NativeLocalConfig(private val context: Context, messenger: BinaryMessenger) : PatapataPlugin, MethodChannel.MethodCallHandler { + private val mChannel : MethodChannel = MethodChannel(messenger, "dev.patapata.native_local_config") + private val mMainScope = CoroutineScope(Dispatchers.Main) + private var mJob: Job? = null + + override val patapataName: String + get() = "dev.patapata.native_local_config" + + override fun patapataEnable() { + mChannel.setMethodCallHandler(this) + + mJob = mMainScope.launch { + context.nativeLocalConfigStore.data + .map { + it.asMap().map { entry -> entry.key.name to entry.value }.toMap() + } + .onEach { + withContext(Dispatchers.Main) { + mChannel.invokeMethod("syncAll", it) + } + } + .cancellable() + .catch { + withContext(Dispatchers.Main) { + mChannel.invokeMethod("error", it.toPatapataMap()) + } + } + .collect() + } + } + + override fun patapataDisable() { + mJob?.cancel() + mJob = null + mChannel.setMethodCallHandler(null) + super.patapataDisable() + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + val tExceptionHandler = CoroutineExceptionHandler { _, exception -> + result.error(Error.PPENLC000.name, null, exception.toPatapataMap()) + } + + mMainScope.launch(tExceptionHandler) { + when (call.method) { + "reset" -> context.nativeLocalConfigStore.edit { + // PreferenceKey's equality only checks the name. + it.remove(stringPreferencesKey(call.arguments as String)) + result.success(null) + } + "resetMany" -> context.nativeLocalConfigStore.edit { store -> + (call.arguments as? List<*>)?.forEach { + // PreferenceKey's equality only checks the name. + store.remove(stringPreferencesKey(it as String)) + } + + result.success(null) + } + "resetAll" -> context.nativeLocalConfigStore.edit { + it.clear() + result.success(null) + } + "setBool" -> context.nativeLocalConfigStore.edit { + val tArgs = call.arguments as List<*> + it[booleanPreferencesKey(tArgs[0] as String)] = tArgs[1] as Boolean + result.success(null) + } + "setInt" -> context.nativeLocalConfigStore.edit { + val tArgs = call.arguments as List<*> + it[intPreferencesKey(tArgs[0] as String)] = tArgs[1] as Int + result.success(null) + } + "setDouble" -> context.nativeLocalConfigStore.edit { + val tArgs = call.arguments as List<*> + it[doublePreferencesKey(tArgs[0] as String)] = tArgs[1] as Double + result.success(null) + } + "setString" -> context.nativeLocalConfigStore.edit { + val tArgs = call.arguments as List<*> + it[stringPreferencesKey(tArgs[0] as String)] = tArgs[1] as String + result.success(null) + } + "setMany" -> context.nativeLocalConfigStore.edit { store -> + (call.arguments as? Map<*, *>)?.forEach { + val tKey = it.key + + if (tKey is String) { + when (val tValue = it.value) { + is Boolean -> store[booleanPreferencesKey(tKey)] = tValue + is Int -> store[intPreferencesKey(tKey)] = tValue + is Double -> store[doublePreferencesKey(tKey)] = tValue + is String -> store[stringPreferencesKey(tKey)] = tValue + } + } + } + result.success(null) + } + } + } + } +} diff --git a/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/PatapataCorePlugin.kt b/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/PatapataCorePlugin.kt new file mode 100644 index 0000000..d230cef --- /dev/null +++ b/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/PatapataCorePlugin.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package dev.patapata.patapata_core + +import android.util.Log +import androidx.annotation.NonNull +import androidx.annotation.Keep +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.loader.FlutterLoader + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import java.io.File + +private class PatapataPluginContainer(val plugin: PatapataPlugin, val flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) + +private val sPlugins = mutableSetOf() + +/** PatapataCorePlugin */ +class PatapataCorePlugin: FlutterPlugin, MethodCallHandler { + private lateinit var mChannel : MethodChannel + private lateinit var mBinding : FlutterPlugin.FlutterPluginBinding + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + mBinding = flutterPluginBinding + // Lines up with Flutter's override of Dart's Directory.systemTemp directory. + val tNativeLibDirectoryInfoFile = File(flutterPluginBinding.applicationContext.codeCacheDir, "patapataNativeLib") + + if (tNativeLibDirectoryInfoFile.exists()) { + tNativeLibDirectoryInfoFile.delete() + } + + tNativeLibDirectoryInfoFile.writeText(flutterPluginBinding.applicationContext.applicationInfo.nativeLibraryDir) + + mChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "dev.patapata.patapata_core") + mChannel.setMethodCallHandler(this) + + // Register default plugins. + flutterPluginBinding.registerPatapataPlugin(NativeLocalConfig(mBinding.applicationContext, mBinding.binaryMessenger)) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "enablePlugin" -> { + val tName = call.arguments as? String + + if (tName == null) { + result.error(Error.PPE000.name, "Invalid plugin name passed to enablePlugin", null) + + return + } + + enablePlugin(tName) + result.success(null) + } + "disablePlugin" -> { + val tName = call.arguments as? String + + if (tName == null) { + result.error(Error.PPE000.name, "Invalid plugin name passed to disablePlugin", null) + + return + } + + disablePlugin(tName) + result.success(null) + } + else -> result.notImplemented() + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + mChannel.setMethodCallHandler(null) + } + + private fun enablePlugin(pluginName: String) { + sPlugins.firstOrNull { + it.plugin.patapataName == pluginName && it.flutterPluginBinding.binaryMessenger == mBinding.binaryMessenger + }?.plugin?.patapataEnable() + } + + private fun disablePlugin(pluginName: String) { + sPlugins.firstOrNull { + it.plugin.patapataName == pluginName && it.flutterPluginBinding.binaryMessenger == mBinding.binaryMessenger + }?.plugin?.patapataDisable() + } +} + +fun FlutterPlugin.FlutterPluginBinding.registerPatapataPlugin(plugin: PatapataPlugin) { + sPlugins.removeAll { it.plugin == plugin } + sPlugins.add(PatapataPluginContainer(plugin, this)) +} + +fun FlutterPlugin.FlutterPluginBinding.unregisterPatapataPlugin(plugin: PatapataPlugin) { + sPlugins.removeAll { it.plugin == plugin } +} diff --git a/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/PatapataPlugin.kt b/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/PatapataPlugin.kt new file mode 100644 index 0000000..2961f24 --- /dev/null +++ b/packages/patapata_core/android/src/main/kotlin/dev/patapata/patapata_core/PatapataPlugin.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package dev.patapata.patapata_core + +interface PatapataPlugin { + val patapataName: String + fun patapataEnable() {} + fun patapataDisable() {} +} diff --git a/packages/patapata_core/bin/bootstrap.dart b/packages/patapata_core/bin/bootstrap.dart new file mode 100644 index 0000000..a474147 --- /dev/null +++ b/packages/patapata_core/bin/bootstrap.dart @@ -0,0 +1,999 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:xml/xml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +void main(List arguments) { + late final ArgParser tParser; + + tParser = ArgParser() + ..addSeparator( + 'Bootstrap your Patapata project.', + ) + ..addFlag( + 'help', + abbr: 'h', + help: 'Show this help message.', + negatable: false, + defaultsTo: false, + ) + ..addFlag( + 'force', + abbr: 'f', + help: 'Rewrite existing files.', + negatable: false, + defaultsTo: false, + ) + ..addFlag( + 'i18n', + help: 'Enable I18n support.', + defaultsTo: true, + ) + ..addMultiOption( + 'locale', + abbr: 'l', + help: 'Which locale codes to support.', + defaultsTo: ['en'], + ) + ..addFlag( + 'errors', + help: 'Enable customization of the error handling system.', + defaultsTo: false, + ) + ..addFlag( + 'log', + help: 'Enable customization of the logging system.', + defaultsTo: true, + ) + ..addFlag( + 'notifications', + help: 'Enable customization of the notifications system.', + defaultsTo: false, + ) + ..addFlag( + 'screenlayout', + help: 'Enable customization of ScreenLayout breakpoints.', + defaultsTo: false, + ); + + try { + final tResults = tParser.parse(arguments); + + if (tResults['help'] == true) { + stdout.writeln(tParser.usage); + return; + } + + if (tResults.rest.isNotEmpty) { + throw UsageException( + 'Unexpected arguments: ${tResults.rest.join(', ')}', + tParser.usage, + ); + } + + // Check if an environment.dart file exists. + // And if not, create it with a default Environment class. + _checkEnvironmentFile(tResults); + + // Check if a splash_page.dart file exists. + // And if not, create it with a default SplashPage class. + _checkSplashPageFile(tResults); + + // Check if a agreement_page.dart file exists. + // And if not, create it with a default AgreementPage class. + _checkAgreementPageFile(tResults); + + // Check if a home_page.dart file exists. + // And if not, create it with a default HomePage class. + _checkHomePageFile(tResults); + + // Check if a startup.dart file exists. + // And if not, create it with several default startup sequence states. + _checkStartupFile(tResults); + + // Check if a errors.dart file exists. + // And if not, create it with some default errors. + _checkErrorsFile(tResults); + + // Check if a main.dart file exists. + // And if not, create it with a default main function. + _checkMainFile(tResults); + + // Check if an AndroidManifest.xml file exists. + // And if so, check if it contains the flutter_deeplinking_enabled meta-data. + _checkAndroidManifestFile(tResults); + + // Check if the minimum version of the Android SDK is set to the flutter default, + // And if it is, change it to 21. + _checkAndroidGradleFile(tResults); + + // Check if Info.plist file contains the FlutterDeepLinkingEnabled key. + // And if not, set it to true. + _checkInfoPlistFile(tResults); + + // Check if the minumum version of iOS is unset. + // If it is, set it to 12.0. + _checkPodFile(tResults); + + // Check if the l10n directory exists. + // And if not, create it. + _checkL10nFiles(tResults); + } catch (e) { + switch (e) { + case UsageException(): + stderr.writeln(e.message); + stderr.writeln(e.usage); + exit(64); + case FormatException(): + stderr.writeln(e.message); + stderr.writeln(tParser.usage); + exit(64); + default: + rethrow; + } + } +} + +void _checkEnvironmentFile(ArgResults results) { + stdout.writeln('Checking environment.dart file...'); + + final tFile = File('lib/src/environment.dart'); + final tFileExists = tFile.existsSync(); + + if (results['force'] == true || !tFileExists) { + stdout.writeln('Creating environment.dart file...'); + final tMixins = { + if (results['i18n'] == true) 'I18nEnvironment', + if (results['errors'] == true) 'ErrorEnvironment', + if (results['log'] == true) 'LogEnvironment', + if (results['notifications'] == true) 'NotificationsEnvironment', + if (results['screenlayout'] == true) 'ScreenLayoutEnvironment', + }; + + if (!tFileExists) { + tFile.createSync(recursive: true); + } + + tFile.writeAsStringSync(DartFormatter().format(''' +import 'package:flutter/foundation.dart'; +import 'package:patapata_core/patapata_core.dart'; +${tMixins.contains('ScreenLayoutEnvironment') ? ''' +import 'package:patapata_core/patapata_widgets.dart'; +''' : ''} +${tMixins.contains('ScreenLayoutEnvironment') || tMixins.contains('LogEnvironment') ? ''' +import 'package:flutter/widgets.dart'; +''' : ''} + +/// The Environment class for this app. +/// Controls all static settings for the app. +/// Pass this to the [App] constructor. +class Environment${tMixins.isNotEmpty ? ' with\n${tMixins.join(',\n')}' : ''} { + + ${tMixins.contains('I18nEnvironment') ? ''' + @override + final List supportedL10ns = const [ +${[ + for (final tLocale in results['locale'] as List) + "Locale('$tLocale')", + ].join(',\n')} + ]; + + @override + final List l10nPaths = const [ + 'l10n', + ]; + +''' : ''} + ${tMixins.contains('ErrorEnvironment') ? ''' + @override + final Map? errorReplacePrefixMap; + + @override + final Widget Function(PatapataException)? errorDefaultWidget; + + @override + final Future Function(BuildContext, PatapataException)? errorDefaultShowDialog; + +''' : ''} +${tMixins.contains('LogEnvironment') ? ''' + @override + final int logLevel; + + @override + final bool printLog; + +''' : ''} +${tMixins.contains('NotificationsEnvironment') ? ''' + @override + final String notificationsAndroidDefaultIcon; + + @override + final bool notificationsDarwinDefaultPresentAlert; + + @override + final bool notificationsDarwinDefaultPresentSound; + + @override + final bool notificationsDarwinDefaultPresentBadge; + + @override + final bool notificationsDarwinDefaultPresentBanner; + + @override + final bool notificationsDarwinDefaultPresentList; + + @override + final List notificationsAndroidChannels; + + @override + final String notificationsPayloadLocationKey; + +''' : ''} +${tMixins.contains('ScreenLayoutEnvironment') ? ''' + @override + final Map screenLayoutBreakpoints; + +''' : ''} + + const Environment({ + ${tMixins.contains('ErrorEnvironment') ? ''' + this.errorReplacePrefixMap, + this.errorDefaultWidget, + this.errorDefaultShowDialog, +''' : ''} + ${tMixins.contains('LogEnvironment') ? ''' + this.logLevel = const int.fromEnvironment('LOG_LEVEL', defaultValue: -kPataInHex), + this.printLog = const bool.fromEnvironment('PRINT_LOG', defaultValue: kDebugMode), +''' : ''} + ${tMixins.contains('NotificationsEnvironment') ? ''' + this.notificationsAndroidDefaultIcon = '@mipmap/ic_launcher', + this.notificationsDarwinDefaultPresentAlert = true, + this.notificationsDarwinDefaultPresentSound = true, + this.notificationsDarwinDefaultPresentBadge = true, + this.notificationsDarwinDefaultPresentBanner = true, + this.notificationsDarwinDefaultPresentList = true, + this.notificationsAndroidChannels = const [ + NotificationsPlugin.kDefaultAndroidChannel, + ], + this.notificationsPayloadLocationKey = 'location', +''' : ''} + ${tMixins.contains('ScreenLayoutEnvironment') ? ''' + this.screenLayoutBreakpoints = const { + 'normal': ScreenLayoutDefaultBreakpoints.normal, + 'large': ScreenLayoutDefaultBreakpoints.large, + }, +''' : ''} + }); +} +''')); + } + + stdout.writeln('Done.'); +} + +void _checkMainFile(ArgResults results) { + stdout.writeln('Checking main.dart file...'); + + final tFile = File('lib/main.dart'); + final tFileExists = tFile.existsSync(); + + if (results['force'] == true || !tFileExists) { + stdout.writeln('Creating main.dart file...'); + + if (!tFileExists) { + tFile.createSync(recursive: true); + } + + tFile.writeAsStringSync(DartFormatter().format(''' +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +import 'src/environment.dart'; +import 'src/startup.dart'; +import 'src/pages/error.dart'; +import 'src/pages/splash_page.dart'; +import 'src/pages/agreement_page.dart'; +import 'src/pages/home_page.dart'; + +final _providerKey = GlobalKey(debugLabel: 'AppProviderKey'); + +void main() { + App( + environment: const Environment(), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StartupStateCheckVersion(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StartupStateAgreements(startupSequence), + [], + ), + ], + ), + createAppWidget: (context, app) => StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + // Splash screen page. + // This uses a special factory that has good defaults for splash screens. + SplashPageFactory( + create: (_) => SplashPage(), + ), + // Agreement page. + // This uses a special factory that all [StartupSequence] pages should use. + StartupPageFactory( + create: (_) => AgreementPage(), + ), + // Error page. + // This uses a special factory that all full screen error pages should use. + StandardErrorPageFactory( + create: (_) => ErrorPage(), + ), + StandardPageFactory( + create: (_) => HomePage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + routableBuilder: (context, child) { + // Setup [ScreenLayout] + // You may want to move this to the body section of your Scaffold + // or somewhere where it makes sense for your app's design. + child = ScreenLayout(child: child); + + // Wrap the app in a key provided by you + // so you can access your providers from anywhere + // via context.read and context.watch. + child = KeyedSubtree( + key: _providerKey, + child: child, + ); + + // If you want to customize a Theme, you can do it here + // by wrapping the child with a Theme widget. + // You can also wrap anything here and that Widget will + // be available to all pages. + + // Add your [Provider]s here + // child = MultiProvider( + // providers: const [ + // // Provider( + // // create: (_) => YourProvider(), + // // ), + // ], + // child: child, + // ); + + return child; + }, + ), + plugins: [], + providerKey: _providerKey, + ) + ..getPlugin()?.enableStandardAppIntegration() + ..run(() async { + // Do any initialization here + // Here's a good default + + // Set a default orientation of only portrait + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitDown, + DeviceOrientation.portraitUp, + ]); + + // Enable Edge-to-Edge mode + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + + // Make the status bars transparent + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + systemNavigationBarColor: Colors.transparent, + statusBarColor: Colors.transparent, + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + )); + + // Set your RemoteConfig defaults here + await getApp().remoteConfig.setDefaults(const {}); + }); +} +''')); + } + + stdout.writeln('Done.'); +} + +void _checkSplashPageFile(ArgResults results) { + stdout.writeln('Checking splash_page.dart file...'); + + final tFile = File('lib/src/pages/splash_page.dart'); + final tFileExists = tFile.existsSync(); + + if (results['force'] == true || !tFileExists) { + stdout.writeln('Creating splash_page.dart file...'); + + if (!tFileExists) { + tFile.createSync(recursive: true); + } + + tFile.writeAsStringSync(DartFormatter().format(''' +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +class SplashPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return const Center( + child: FlutterLogo( + size: 128, + ), + ); + } +} +''')); + } + + stdout.writeln('Done.'); +} + +void _checkAgreementPageFile(ArgResults results) { + stdout.writeln('Checking agreement_page.dart file...'); + + final tFile = File('lib/src/pages/agreement_page.dart'); + final tFileExists = tFile.existsSync(); + + if (results['force'] == true || !tFileExists) { + stdout.writeln('Creating agreement_page.dart file...'); + + if (!tFileExists) { + tFile.createSync(recursive: true); + } + + tFile.writeAsStringSync(DartFormatter().format(''' +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +class AgreementPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.agreement.title')), + ), + body: Column( + children: [ + Center( + child: Text( + l(context, 'pages.agreement.body'), + ), + ), + TextButton( + child: Text(l(context, 'pages.agreement.yes')), + onPressed: () { + pageData(null); + }, + ), + TextButton( + child: Text(l(context, 'pages.agreement.no')), + onPressed: () { + getApp().startupSequence?.resetMachine(); + }, + ), + ], + ), + ); + } +} +''')); + } + + stdout.writeln('Done.'); +} + +void _checkHomePageFile(ArgResults results) { + stdout.writeln('Checking home_page.dart file...'); + + final tFile = File('lib/src/pages/home_page.dart'); + final tFileExists = tFile.existsSync(); + + if (results['force'] == true || !tFileExists) { + stdout.writeln('Creating home_page.dart file...'); + + if (!tFileExists) { + tFile.createSync(recursive: true); + } + + tFile.writeAsStringSync(DartFormatter().format(''' +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +class HomePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.home.title')), + ), + body: Center( + child: Text(l(context, 'pages.home.body')), + ), + ); + } +} +''')); + } + + stdout.writeln('Done.'); +} + +void _checkStartupFile(ArgResults results) { + stdout.writeln('Checking startup.dart file...'); + + final tFile = File('lib/src/startup.dart'); + final tFileExists = tFile.existsSync(); + + if (results['force'] == true || !tFileExists) { + stdout.writeln('Creating startup.dart file...'); + + if (!tFileExists) { + tFile.createSync(recursive: true); + } + + tFile.writeAsStringSync(DartFormatter().format(''' +import 'package:patapata_core/patapata_core.dart'; + +import 'errors.dart'; +import 'pages/agreement_page.dart'; + +class StartupStateCheckVersion extends StartupState { + StartupStateCheckVersion(StartupSequence startupSequence) : super(startupSequence); + + @override + Future process(Object? data) async { + // Check the version here + // If the version is not supported, throw an exception + // and the app will show the error page. + // If the version is supported, transition to the next state. + final tIsNewestVersion = true; // TODO: Change this with your own logic. + + if (!tIsNewestVersion) { + throw const AppVersionException(); + } + } +} + +class StartupStateAgreements extends StartupState { + /// The version of the agreement. + /// This should be incremented when the agreement changes. + static const kVersion = '1'; + + static const _kAgreementVersionKey = 'agreementVersion'; + + StartupStateAgreements(StartupSequence startupSequence) : super(startupSequence); + + @override + Future process(Object? data) async { + // Check if the user has agreed to the agreement. + // If the user has agreed, return. + if (getApp().localConfig.getString(_kAgreementVersionKey) == kVersion) { + return; + } + + // Show the agreement page here. + // If the user agrees, call pageData(null); + // If the user does not agree, call getApp().startupSequence?.resetMachine(); + // which will reset the startup sequence. + if (await navigateToPage(AgreementPage, (result) {})) { + await getApp().localConfig.setString(_kAgreementVersionKey, kVersion); + } + } +} +''')); + } + + stdout.writeln('Done.'); +} + +void _checkErrorsFile(ArgResults results) { + stdout.writeln('Checking errors.dart file...'); + + final tFile = File('lib/src/errors.dart'); + final tFileExists = tFile.existsSync(); + + if (results['force'] == true || !tFileExists) { + stdout.writeln('Creating errors.dart file...'); + + if (!tFileExists) { + tFile.createSync(recursive: true); + } + + tFile.writeAsStringSync(DartFormatter().format(''' +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +abstract base class AppException extends PatapataException { + const AppException({ + super.app, + super.message, + super.original, + super.fingerprint, + super.localeTitleData, + super.localeMessageData, + super.localeFixData, + super.fix, + super.logLevel, + super.userLogLevel, + }); + + @override + // TODO: This should be a 3 letter code that is unique to your app. + // TODO: We recommend using a mapping like the first two letters being + // TODO: related to your app, and the last letter being related to the + // TODO: type of error. E for error, W for warning, etc. + String get defaultPrefix => 'APE'; + + @override + String get namespace => 'app'; +} + +/// An exception that is thrown when the app encounters an unknown error. +final class AppUnknownException extends AppException { + const AppUnknownException(); + + @override + String get internalCode => '000'; +} + +/// Thrown when an unsupported version (usually old) of the app is detected. +final class AppVersionException extends AppException { + const AppVersionException() : super(logLevel: Level.INFO); + + @override + String get internalCode => '010'; + + @override + void onReported(ReportRecord record) { + showDialog(getApp().navigatorContext); + } + + @override + Future Function()? get fix => () async { + // Launch the app store. + }; +} +''')); + } + + // Also check and create the ErrorPage. + final tErrorPageFile = File('lib/src/pages/error.dart'); + final tErrorPageFileExists = tErrorPageFile.existsSync(); + + if (results['force'] == true || !tErrorPageFileExists) { + stdout.writeln('Creating error.dart file...'); + + if (!tErrorPageFileExists) { + tErrorPageFile.createSync(recursive: true); + } + + tErrorPageFile.writeAsStringSync(DartFormatter().format(''' +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +import '../errors.dart'; + +class ErrorPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + if (pageData.error is AppException) { + return _buildAppExceptionPage(context); + } else { + return _buildUnknownExceptionPage(context); + } + } + + Widget _buildAppExceptionPage(BuildContext context) { + final tAppException = pageData.error as AppException; + + return Scaffold( + appBar: AppBar( + title: Text(tAppException.localizedTitle), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Text(tAppException.localizedMessage), + if (tAppException.hasFix) + TextButton( + child: Text(tAppException.localizedFix), + onPressed: () { + tAppException.fix!(); + }, + ), + ], + ), + ), + ); + } + + Widget _buildUnknownExceptionPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'errors.app.000.title')), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Text(l(context, 'errors.app.000.message')), + ], + ), + ), + ); + } +} +''')); + } + + stdout.writeln('Done.'); +} + +void _checkAndroidManifestFile(ArgResults results) { + stdout.writeln('Checking AndroidManifest.xml file...'); + + // Check if the AndroidManifest.xml file contains an main activity that has + // meta-data of flutter_deeplinking_enabled, and if not, add it. + final tFile = File('android/app/src/main/AndroidManifest.xml'); + final tFileExists = tFile.existsSync(); + + if (!tFileExists) { + stdout.writeln('AndroidManifest.xml file not found.'); + + return; + } + + final tDocument = XmlDocument.parse(tFile.readAsStringSync()); + final tMainActivities = + tDocument.findAllElements('activity').where((element) { + final tIntentFilters = + element.findAllElements('intent-filter').where((element) { + final tActions = element.findAllElements('action').where((element) { + return element.getAttribute('android:name') == + 'android.intent.action.MAIN'; + }); + + final tCategories = element.findAllElements('category').where((element) { + return element.getAttribute('android:name') == + 'android.intent.category.LAUNCHER'; + }); + + return tActions.isNotEmpty && tCategories.isNotEmpty; + }); + + return tIntentFilters.isNotEmpty; + }); + + if (tMainActivities.isEmpty) { + stdout.writeln('No main activity found.'); + + return; + } + + final tMainActivity = tMainActivities.first; + + final tMetaData = tMainActivity.findAllElements('meta-data').where((element) { + return element.getAttribute('android:name') == + 'flutter_deeplinking_enabled'; + }); + + if (tMetaData.isEmpty) { + stdout.writeln( + 'No meta-data found. Adding the Flutter Deep Linking feature.'); + + tMainActivity.children.add(XmlElement( + XmlName('meta-data'), + [ + XmlAttribute(XmlName('android:name'), 'flutter_deeplinking_enabled'), + XmlAttribute(XmlName('android:value'), 'true'), + ], + [], + false, + )); + + tFile.writeAsStringSync(tDocument.toXmlString(pretty: true)); + } +} + +void _checkAndroidGradleFile(ArgResults results) { + stdout.writeln('Checking Android Gradle file...'); + + // Check if the Android Gradle file contains the minimum SDK version of 21. + // And if not, change it to 21. + final tFile = File('android/app/build.gradle'); + final tFileExists = tFile.existsSync(); + + if (!tFileExists) { + stdout.writeln('Android Gradle file not found.'); + + return; + } + + // Replace the minimum SDK version with 21 as plain text + final tDocument = tFile + .readAsStringSync() + .replaceAll('minSdkVersion flutter.minSdkVersion', 'minSdkVersion 21'); + + tFile.writeAsStringSync(tDocument.toString()); +} + +void _checkInfoPlistFile(ArgResults results) { + stdout.writeln('Checking Info.plist file...'); + + // Check if the Info.plist file contains the FlutterDeepLinkingEnabled key. + // And if not, set it to true. + final tFile = File('ios/Runner/Info.plist'); + final tFileExists = tFile.existsSync(); + + if (!tFileExists) { + stdout.writeln('Info.plist file not found.'); + + return; + } + + final tDocument = XmlDocument.parse(tFile.readAsStringSync()); + // This should be the first element under plist. + final tDictElement = tDocument.findAllElements('dict').first; + final tKeys = tDictElement.findAllElements('key').where((element) { + return element.innerText == 'FlutterDeepLinkingEnabled'; + }); + + if (tKeys.isEmpty) { + stdout.writeln( + 'No FlutterDeepLinkingEnabled key found. Adding the Flutter Deep Linking feature.'); + + tDictElement.children.add(XmlElement( + XmlName('key'), + [], + [ + XmlText('FlutterDeepLinkingEnabled'), + ], + false, + )); + + tDictElement.children.add(XmlElement( + XmlName('true'), + [], + [], + true, + )); + + tFile.writeAsStringSync(tDocument.toXmlString(pretty: true)); + } + + stdout.writeln('Done.'); +} + +void _checkPodFile(ArgResults results) { + stdout.writeln('Checking Podfile file...'); + + // Check if the Podfile file contains the minimum version of iOS of 12.0. + // And if not, change it to 12.0. + final tFile = File('ios/Podfile'); + final tFileExists = tFile.existsSync(); + + if (!tFileExists) { + stdout.writeln('Podfile file not found.'); + + return; + } + + // Replace the minimum version of iOS with 12.0 as plain text + // The default setting after flutter create is a commented out line. + // So we remove that whole line and replace it. + // As of writting this code, the default line is: + // # platform :ios, '11.0' + final tDocument = tFile + .readAsStringSync() + .replaceAll('# platform :ios, \'11.0\'', 'platform :ios, \'12.0\''); + + tFile.writeAsStringSync(tDocument.toString()); +} + +void _checkL10nFiles(ArgResults results) { + stdout.writeln('Checking l10n files...'); + + // Check if the l10n directory exists. + // And if not, create it. + final tDirectory = Directory('l10n'); + final tDirectoryExists = tDirectory.existsSync(); + + if (!tDirectoryExists) { + stdout.writeln('l10n directory not found. Creating it.'); + tDirectory.createSync(recursive: true); + } + + // Check if each of the Locales specified in the arguments exists. + // If not, create them. + for (final tLocale in results['locale'] as List) { + final tFile = File('l10n/$tLocale.yaml'); + final tFileExists = tFile.existsSync(); + + if (!tFileExists) { + stdout.writeln('l10n/$tLocale.yaml file not found. Creating it.'); + tFile.createSync(recursive: true); + + tFile.writeAsStringSync(''' +title: App Title +pages: + agreement: + title: Agreement + body: This is the agreement page. Do you accept? + yes: Yes + no: No + home: + title: Home + body: This is the home page. +errors: + app: + '000': + title: Unknown Error + message: An unknown error has occurred. + '010': + title: Unsupported Version + message: This version of the app is no longer supported. + fix: Please update the app. +'''); + } + } + + stdout.writeln('Adding l10n.yaml files to flutter assets in pubspec.yaml...'); + + final tPubspecFile = File('pubspec.yaml'); + final tPubspecFileExists = tPubspecFile.existsSync(); + + if (!tPubspecFileExists) { + stdout.writeln('pubspec.yaml file not found.'); + + return; + } + + final tPubspecDocument = YamlEditor(tPubspecFile.readAsStringSync()); + + tPubspecDocument.parseAt( + ['flutter', 'assets'], + orElse: () { + tPubspecDocument.update([ + 'flutter' + ], { + 'assets': [], + }); + + return tPubspecDocument.parseAt(['flutter', 'assets']); + }, + ); + + for (final tLocale in results['locale'] as List) { + tPubspecDocument.appendToList(['flutter', 'assets'], 'l10n/$tLocale.yaml'); + } + + tPubspecFile.writeAsStringSync(tPubspecDocument.toString()); + + stdout.writeln('Done.'); +} diff --git a/packages/patapata_core/dartdoc_options.yaml b/packages/patapata_core/dartdoc_options.yaml new file mode 100644 index 0000000..ad58258 --- /dev/null +++ b/packages/patapata_core/dartdoc_options.yaml @@ -0,0 +1,15 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + exclude: + - patapata_core_libs + - local_config_finder + - patapata_core_web + - patapata_web_plugin + - web_local_config + - web_local_config_finder + - native_ffi_finder + - native_local_config diff --git a/packages/patapata_core/ios/.gitignore b/packages/patapata_core/ios/.gitignore new file mode 100644 index 0000000..0c88507 --- /dev/null +++ b/packages/patapata_core/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/packages/patapata_core/ios/Assets/.gitkeep b/packages/patapata_core/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/patapata_core/ios/Classes/NativeLocalConfig.swift b/packages/patapata_core/ios/Classes/NativeLocalConfig.swift new file mode 100644 index 0000000..bce9bda --- /dev/null +++ b/packages/patapata_core/ios/Classes/NativeLocalConfig.swift @@ -0,0 +1,128 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Flutter + +class NativeLocalConfig : PatapataPlugin { + fileprivate let mChannel: FlutterMethodChannel + fileprivate var mOnChangeListener: Any? + fileprivate var mOnSizeLimitExceededListener: Any? + fileprivate let mStore = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier ?? "unknown").dev.patapata.native_local_config") ?? UserDefaults.standard + + init(registrar: FlutterPluginRegistrar) { + mChannel = FlutterMethodChannel(name: "dev.patapata.native_local_config", binaryMessenger: registrar.messenger()) + } + + public var patapataName: String = "dev.patapata.native_local_config" + + public func patapataEnable() { + mChannel.setMethodCallHandler(handle) + + mOnChangeListener = NotificationCenter.default.addObserver(forName: UserDefaults.didChangeNotification, object: mStore, queue: OperationQueue.main, using: onChange) + + if #available(iOS 9.3, *) { + mOnSizeLimitExceededListener = NotificationCenter.default.addObserver(forName: UserDefaults.sizeLimitExceededNotification, object: mStore, queue: OperationQueue.main, using: onSizeLimitExceeded) + } + + syncStore() + } + + fileprivate func syncStore() { + let tDict = mStore.dictionaryRepresentation().filter { + switch $0.value { + case is Bool: + return true + case is Int: + return true + case is Double: + return true + case is String: + return true + default: + return false + } + } + + mChannel.invokeMethod("syncAll", arguments: tDict) + } + + fileprivate func onChange(_: Notification) { + syncStore() + } + + fileprivate func onSizeLimitExceeded(notification: Notification) { + // mChannel.invokeMethod("error", arguments: <#T##Any?#>) + // Should we send this? It could happen async from a set command + // It could also happen from something not related to patapata at all... + } + + public func patapataDisable() { + mChannel.setMethodCallHandler(nil) + NotificationCenter.default.removeObserver(mOnChangeListener!, name: UserDefaults.didChangeNotification, object: mStore) + mOnChangeListener = nil + + if #available(iOS 9.3, *) { + NotificationCenter.default.removeObserver(mOnSizeLimitExceededListener!, name: UserDefaults.sizeLimitExceededNotification, object: mStore) + mOnSizeLimitExceededListener = nil + } + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "reset": + mStore.removeObject(forKey: call.arguments as! String) + result(nil) + break + case "resetMany": + let tArgs = call.arguments as! Array + + for i in tArgs { + mStore.removeObject(forKey: i) + } + + result(nil) + break + case "resetAll": + for i in mStore.dictionaryRepresentation() { + mStore.removeObject(forKey: i.key) + } + + result(nil) + break + case "setBool": + let tArgs = call.arguments as! Array + mStore.set(tArgs[1] as! Bool, forKey: tArgs[0] as! String) + result(nil) + break + case "setInt": + let tArgs = call.arguments as! Array + mStore.set(tArgs[1] as! Int, forKey: tArgs[0] as! String) + result(nil) + break + case "setDouble": + let tArgs = call.arguments as! Array + mStore.set(tArgs[1] as! Double, forKey: tArgs[0] as! String) + result(nil) + break + case "setString": + let tArgs = call.arguments as! Array + mStore.set(tArgs[1] as! String, forKey: tArgs[0] as! String) + result(nil) + break + case "setMany": + let tArgs = call.arguments as! Dictionary + + for i in tArgs { + mStore.set(i.value, forKey: i.key) + } + + result(nil) + break + default: + result(FlutterMethodNotImplemented) + break + } + } +} diff --git a/packages/patapata_core/ios/Classes/PatapataCorePlugin.swift b/packages/patapata_core/ios/Classes/PatapataCorePlugin.swift new file mode 100644 index 0000000..d36dc26 --- /dev/null +++ b/packages/patapata_core/ios/Classes/PatapataCorePlugin.swift @@ -0,0 +1,128 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Flutter +import UIKit +import AppTrackingTransparency + +fileprivate var sPlugins = Set() + +public class PatapataCorePlugin: NSObject, FlutterPlugin { + let mRegistrar: FlutterPluginRegistrar + let mChannel: FlutterMethodChannel + + init(registrar: FlutterPluginRegistrar) { + mRegistrar = registrar + mChannel = FlutterMethodChannel(name: "dev.patapata.patapata_core", binaryMessenger: registrar.messenger()) + + super.init() + + registrar.addMethodCallDelegate(self, channel: mChannel) + + // Register default plugins. + registrar.registerPatapata(plugin: NativeLocalConfig(registrar: registrar)) + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let _ = PatapataCorePlugin(registrar: registrar) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "enablePlugin": + guard let tName = call.arguments as? String else { + result(FlutterError(code: "PPE000", message: "Invalid plugin name passed to enablePlugin", details: nil)) + + return + } + + enablePlugin(with: tName) + result(nil) + case "disablePlugin": + guard let tName = call.arguments as? String else { + result(FlutterError(code: "PPE000", message: "Invalid plugin name passed to disablePlugin", details: nil)) + + return + } + + disablePlugin(with: tName) + result(nil) + case "Permissions:requestTracking": + requestTrackingPermission(result) + default: + result(FlutterMethodNotImplemented) + } + } + + public func enablePlugin(with pluginName: String) { + for i in sPlugins { + guard i.plugin.patapataName == pluginName && i.registrar.messenger().hash == mRegistrar.messenger().hash else { + continue + } + + i.plugin.patapataEnable(); + } + } + + public func disablePlugin(with pluginName: String) { + for i in sPlugins { + guard i.plugin.patapataName == pluginName && i.registrar.messenger().hash == mRegistrar.messenger().hash else { + continue + } + + i.plugin.patapataDisable() + } + } + + func requestTrackingPermission(_ result: @escaping FlutterResult) { + if #available(iOS 14, *) { + ATTrackingManager.requestTrackingAuthorization { (status) in + switch status { + case .authorized: + result("authorized") + break + case .denied: + result("denied") + break + case .notDetermined: + result("notDetermined") + break + case .restricted: + result("restricted") + break + default: + result(nil) + } + } + } else { + result(nil) + } + } +} + +fileprivate struct PatapataPluginContainer : Hashable { + let plugin: PatapataPlugin + let registrar: FlutterPluginRegistrar + + static func == (lhs: PatapataPluginContainer, rhs: PatapataPluginContainer) -> Bool { + return lhs.plugin.patapataName == rhs.plugin.patapataName && lhs.registrar.messenger().hash == rhs.registrar.messenger().hash + } + + func hash(into hasher: inout Hasher) { + hasher.combine(plugin.patapataName) + hasher.combine(registrar.messenger().hash) + } +} + + +extension FlutterPluginRegistrar { + public func registerPatapata(plugin: PatapataPlugin) { + sPlugins.insert(PatapataPluginContainer(plugin: plugin, registrar: self)) + } + + public func unregisterPatapata(plugin: PatapataPlugin) { + sPlugins.remove(PatapataPluginContainer(plugin: plugin, registrar: self)) + } +} diff --git a/packages/patapata_core/ios/Classes/PatapataCorePluginBridge.h b/packages/patapata_core/ios/Classes/PatapataCorePluginBridge.h new file mode 100644 index 0000000..c068d98 --- /dev/null +++ b/packages/patapata_core/ios/Classes/PatapataCorePluginBridge.h @@ -0,0 +1,11 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface PatapataCorePluginBridge : NSObject +@end diff --git a/packages/patapata_core/ios/Classes/PatapataCorePluginBridge.m b/packages/patapata_core/ios/Classes/PatapataCorePluginBridge.m new file mode 100644 index 0000000..951eb16 --- /dev/null +++ b/packages/patapata_core/ios/Classes/PatapataCorePluginBridge.m @@ -0,0 +1,20 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#import "PatapataCorePluginBridge.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "patapata_core-Swift.h" +#endif + +@implementation PatapataCorePluginBridge ++ (void)registerWithRegistrar:(NSObject*)registrar { + [PatapataCorePlugin registerWithRegistrar:registrar]; +} +@end diff --git a/packages/patapata_core/ios/Classes/PatapataPlugin.swift b/packages/patapata_core/ios/Classes/PatapataPlugin.swift new file mode 100644 index 0000000..c9ca8f6 --- /dev/null +++ b/packages/patapata_core/ios/Classes/PatapataPlugin.swift @@ -0,0 +1,16 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + + +public protocol PatapataPlugin { + var patapataName: String { get } + func patapataEnable() + func patapataDisable() +} + +public extension PatapataPlugin { + func patapataEnable() {} + func patapataDisable() {} +} diff --git a/packages/patapata_core/ios/patapata_core.podspec b/packages/patapata_core/ios/patapata_core.podspec new file mode 100644 index 0000000..0d98a49 --- /dev/null +++ b/packages/patapata_core/ios/patapata_core.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint patapata_core.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'patapata_core' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '8.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/packages/patapata_core/lib/finder/local_config_finder.dart b/packages/patapata_core/lib/finder/local_config_finder.dart new file mode 100644 index 0000000..b93a734 --- /dev/null +++ b/packages/patapata_core/lib/finder/local_config_finder.dart @@ -0,0 +1,9 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:patapata_core/patapata_core.dart'; + +LocalConfigFinder getLocalConfigFinder() => + throw UnsupportedError('Cannot create a LocalConfigFinder'); diff --git a/packages/patapata_core/lib/patapata_core.dart b/packages/patapata_core/lib/patapata_core.dart new file mode 100644 index 0000000..4274735 --- /dev/null +++ b/packages/patapata_core/lib/patapata_core.dart @@ -0,0 +1,31 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_core; + +export 'src/util.dart'; +export 'src/sequential_work_queue.dart'; +export 'src/logic_state.dart'; +export 'src/provider_model.dart'; +export 'src/config.dart'; +export 'src/remote_config.dart'; +export 'src/local_config.dart'; +export 'src/remote_messaging.dart'; +export 'src/log.dart'; +export 'src/analytics.dart'; +export 'src/user.dart'; +export 'src/network.dart'; +export 'src/package_info.dart'; +export 'src/device_info.dart'; +export 'src/plugin.dart'; +export 'src/app.dart'; +export 'src/i18n.dart'; +export 'src/permissions.dart'; +export 'src/notifications.dart'; +export 'src/error.dart'; +export 'src/startup.dart'; + +export 'src/native_local_config.dart'; +export 'src/method_channel_test_mixin.dart'; diff --git a/packages/patapata_core/lib/patapata_core_libs.dart b/packages/patapata_core/lib/patapata_core_libs.dart new file mode 100644 index 0000000..ab6cd19 --- /dev/null +++ b/packages/patapata_core/lib/patapata_core_libs.dart @@ -0,0 +1,11 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_core_libs; + +export 'dart:async'; + +export 'package:logging/logging.dart'; +export 'package:timezone/timezone.dart'; diff --git a/packages/patapata_core/lib/patapata_core_web.dart b/packages/patapata_core/lib/patapata_core_web.dart new file mode 100644 index 0000000..047edd7 --- /dev/null +++ b/packages/patapata_core/lib/patapata_core_web.dart @@ -0,0 +1,67 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:patapata_core/web/patapata_web_plugin.dart'; +import 'package:patapata_core/web/web_local_config.dart'; + +var _sPlugins = {}; + +class PatapataCoreWeb { + static void registerWith(Registrar registrar) { + final MethodChannel channel = MethodChannel( + 'dev.patapata.patapata_core', + const StandardMethodCodec(), + registrar, + ); + + final tPluginInstance = PatapataCoreWeb(); + channel.setMethodCallHandler(tPluginInstance.handleMethodCall); + + _sPlugins.add(WebLocalConfig(registrar)); + } + + Future handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'enablePlugin': + var tName = call.arguments as String?; + if (tName == null) { + return; + } + return enablePlugin(tName); + case 'disablePlugin': + var tName = call.arguments as String?; + if (tName == null) { + return; + } + return disablePlugin(tName); + default: + throw PlatformException( + code: 'Unimplemented', + details: + 'patapata_core for web doesn\'t implement \'${call.method}\'', + ); + } + } + + void enablePlugin(String pluginName) { + for (var plugin in _sPlugins) { + if (plugin.patapataName == pluginName) { + plugin.patapataEnable(); + return; + } + } + } + + void disablePlugin(String pluginName) { + for (var plugin in _sPlugins) { + if (plugin.patapataName == pluginName) { + plugin.patapataDisable(); + return; + } + } + } +} diff --git a/packages/patapata_core/lib/patapata_widgets.dart b/packages/patapata_core/lib/patapata_widgets.dart new file mode 100644 index 0000000..01e8eb2 --- /dev/null +++ b/packages/patapata_core/lib/patapata_widgets.dart @@ -0,0 +1,10 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_widgets; + +export 'src/widgets/standard_app.dart'; +export 'src/widgets/screen_layout.dart'; +export 'src/widgets/platform_dialog.dart'; diff --git a/packages/patapata_core/lib/src/analytics.dart b/packages/patapata_core/lib/src/analytics.dart new file mode 100644 index 0000000..6f3ca13 --- /dev/null +++ b/packages/patapata_core/lib/src/analytics.dart @@ -0,0 +1,1291 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +final _logger = Logger('patapata.Analytics'); + +class _AnalyticsGlobalContextRouteKey {} + +/// A mixin for an [App.environment] that can filter [AnalyticsEvent]s. +mixin AnalyticsEventFilterEnvironment { + /// A map of [Type] to a function that takes an [AnalyticsEvent] and returns + /// an [AnalyticsEvent] or null if the event should be filtered. + /// The [Type] is usually a [Plugin] type or another custom type. + /// You can use this to force certain events to only be sent to certain + /// plugins or other types as well as transform the event. + Map + get analyticsEventFilter; +} + +/// An analytics class that notifies and monitors information related to events within the app, +/// user tracking, and more, to external services or within the app itself. +class Analytics { + final _eventStreamController = StreamController.broadcast(); + + /// The stream of [AnalyticsEvent] that are sent via [event], [rawEvent], and other method that send events. + Stream get events => _eventStreamController.stream; + + /// Listen to analytics events. + /// These will be filtered by checking [T] in the + /// [App.environment] if it's a [AnalyticsEventFilterEnvironment] + /// to see if a given event should be sent to that type [T]. + Stream eventsFor() { + return events + .map((event) { + final tEnvironment = + getApp().environmentAs(); + + if (tEnvironment?.analyticsEventFilter.containsKey(T) == true) { + return tEnvironment!.analyticsEventFilter[T]!(event); + } + return event; + }) + .where((event) => event != null) + .map((event) => event!); + } + + late final _globalContext = _MultiAnalyticsContext() + ..add(_globalContextRouteKey, _globalRouteContext); + + /// Analytics Context found in the route. + AnalyticsContext get globalContext => _globalContext; + + /// Adds an [AnalyticsContext] with the [key] name to the global context. + /// Passing null for [context] will remove it from the global context. + /// + /// example: + /// + /// ```dart + /// // If you want to add context information to all events + /// final _key = Object(); + /// getApp().analytics.setGlobalContext(_key, AnalyticsContext( + /// data: { + /// 'key1': 1, + /// }, + /// )); + /// // When you want to remove it + /// getApp().analytics.setGlobalContext(_key, null); + /// ``` + void setGlobalContext(Object key, AnalyticsContext? context) { + _logger.fine('Changing global context: key: $key, context: $context'); + + if (context == null) { + _globalContext.remove(key); + } else { + _globalContext.add(key, context); + } + } + + /// The [AnalyticsContext] that was set with [key] in the global context. + AnalyticsContext? getGlobalContext(Object key) => _globalContext.get(key); + + final _globalContextRouteKey = _AnalyticsGlobalContextRouteKey(); + final _globalRouteContext = _MultiAnalyticsContext(); + + /// Add an [AnalyticsContext] with the [key] name to the global route context. + /// If you pass null for the argument [context] of this function, it will be removed from the global route context. + /// The values set in this Route Context will disappear when transitioning to other pages. + /// example: + /// + /// ```dart + /// final _key = Object(); + /// getApp().analytics.setRouteContext(_key, AnalyticsContext( + /// data: { + /// 'key1': 1, + /// }, + /// )); + /// + /// // When deleting manually + /// getApp().analytics.setRouteContext(_key, null); + /// ``` + void setRouteContext(Object key, AnalyticsContext? context) { + if (context == null) { + _globalRouteContext.remove(key); + } else { + _globalRouteContext.add(key, context); + } + } + + /// The [AnalyticsContext] that was set with [key] in the global route context. + AnalyticsContext? getRouteContext(Object key) => _globalRouteContext.get(key); + + AnalyticsContext? __interactionContext; + Map? _interactionContextData; + + /// A Map of data from the Context of a Widget with the most recently interacted (touched) with Analytics information by the user. + Map? get interactionContextData => _interactionContextData; + set _interactionContext(AnalyticsContext? context) { + if (context != __interactionContext) { + _logger.fine('Changing interaction context: $context'); + __interactionContext = context; + _interactionContextData = context?.resolve(); + } + } + + Map? _navigationInteractionContextData; + + /// A Map of data to retain analytics information for page transitions. + Map? get navigationInteractionContextData => + _navigationInteractionContextData; + + void _promoteInteractionContextToNavigationContext() { + _logger.finer('Promoting interaction context...'); + _navigationInteractionContextData = interactionContextData; + _interactionContext = null; + } + + /// Send analytics for the information in [event]. + /// Used when you want to send custom analytics events. + /// + /// example: + /// + /// ```dart + /// getApp().analytics.rawEvent(AnalyticsEvent( + /// name: 'Custom Hogehoge Event', + /// )); + /// ``` + void rawEvent( + AnalyticsEvent event, { + Level logLevel = Level.INFO, + }) { + event._navigationInteractionContextData = navigationInteractionContextData; + + _logger.log(logLevel, () => '$event'); + _eventStreamController.add(event); + } + + /// Send analytics for the event with the name [name]. + /// When you cannot use Patapata's standard Analytics-related Widget or when you want to manually send events, you can follow this approach. + /// + /// example: + /// + /// ```dart + /// getApp().analytics.event( + /// name: 'Hogehoge Event', + /// ); + /// ``` + void event({ + required String name, + Map? data, + BuildContext? context, + Level logLevel = Level.INFO, + }) { + AnalyticsContext? tAnalyticsContext; + + if (context != null) { + tAnalyticsContext = Provider.of(context, listen: false); + } else { + tAnalyticsContext = globalContext; + } + + final tEvent = AnalyticsEvent( + name: name, + data: data, + context: tAnalyticsContext, + ); + + tEvent._navigationInteractionContextData = navigationInteractionContextData; + + _logger.log(logLevel, () => '$tEvent'); + _eventStreamController.add(tEvent); + } + + /// Send analytics for the [route]. + /// Used when you want to send events related to page transitions. + /// + /// example: + /// + /// ```dart + /// getApp().analytics.routeViewEvent( + /// route: Route.of(context), + /// navigationType: AnalyticsNavigationType.push, + /// ); + /// ``` + void routeViewEvent( + Route route, { + String navigationType = AnalyticsNavigationType.push, + }) { + _promoteInteractionContextToNavigationContext(); + rawEvent( + AnalyticsRouteViewEvent( + analytics: this, + route: route, + navigationType: navigationType, + ), + ); + } + + /// Send analytics for revenue. + /// 'Revenue' refers to numeric values related to sales or earnings. + /// + /// example: + /// + /// ```dart + /// getApp().analytics.revenueEvent( + /// revenue: 100.0, + /// ); + /// ``` + void revenueEvent({ + required double revenue, + String? currency, + String? orderId, + String? receipt, + String? productId, + String? productName, + String? eventName, + Map? data, + AnalyticsContext? context, + Level logLevel = Level.INFO, + }) { + rawEvent( + AnalyticsRevenueEvent( + revenue: revenue, + currency: currency, + orderId: orderId, + receipt: receipt, + productId: productId, + productName: productName, + eventName: eventName, + context: context, + ), + logLevel: logLevel, + ); + } + + /// Convert the data type of [object] to int, double, or String using the default judgment and return it. + /// Additionally, in this function, data for the Analytics system trims the value side of key-value pairs to a maximum of 100 characters. + /// This is because the average length limitation for the value strings of third-party Analytics systems is around 100 characters. + static Object? defaultMakeLoggableToNative(Object? object) { + if (object == null) { + return ''; + } else if (object is int || object is double || object is String) { + if (object is String) { + return object.characters.take(100).toString(); + } + + return object; + } else { + try { + return jsonEncode(object).characters.take(100).toString(); + } catch (_) { + return object.toString(); + } + } + } + + static const _kMaxJsonParameterLength = 100; + + /// Convert [object] into a loggable JSON parameter with the prefix [prefix]. + static Map tryConvertToLoggableJsonParameters( + String prefix, Object? object) { + if (object == null) { + return const {}; + } + + final String tJson; + + if (object is String) { + tJson = object; + } else if (object is int || object is double) { + tJson = object.toString(); + } else { + try { + tJson = jsonEncode(object); + } catch (e) { + return const {}; + } + } + + final tMap = {}; + final tCharacters = tJson.characters; + + var i = 0; + for (;; i++) { + final tPart = tCharacters.getRange( + _kMaxJsonParameterLength * i, _kMaxJsonParameterLength * (i + 1)); + + if (tPart.isEmpty) { + break; + } + + tMap['$prefix${i + 1}'] = tPart.toString(); + } + + return tMap; + } + + @override + String toString() => 'interactionContext:$interactionContextData'; +} + +/// Analytics event class +class AnalyticsEvent { + /// The name of this event. + final String name; + + /// Analytics data. + final Map? data; + + /// A Map created from the AnalyticsContext passed to this event. + final Map? contextData; + + Map? _navigationInteractionContextData; + + /// Data for NavigationInteractionContext. + Map? get navigationInteractionContextData => + _navigationInteractionContextData; + + /// Creates an [AnalyticsEvent]. + /// Specify the name for this [AnalyticsEvent] to be created with [name], and pass the data for this event to [data]. + /// If you provide [context], it will merge the data specified in [context] in to [data] with [data] being prioritized. + AnalyticsEvent({ + required this.name, + this.data, + AnalyticsContext? context, + }) : contextData = context?.resolve(); + + /// A flat map from [contextData] and [data]. + Map? get flatData => {} + ..addAll(contextData ?? const {}) + ..addAll(data ?? const {}) + ..removeWhere((key, value) => value == null); + + @override + String toString() => + 'AnalyticsEvent:$name: data=$data, context=$contextData, navigationInteractionContext=$navigationInteractionContextData'; + + @override + operator ==(Object other) => other is AnalyticsEvent + ? name == other.name && + mapEquals(data, other.data) && + mapEquals(contextData, contextData) + : false; + + @override + int get hashCode => Object.hashAll([ + name, + const MapEquality().hash(data), + const MapEquality().hash(contextData), + ]); +} + +/// Context class for using analytics functionality, +/// to be used in conjunction with [AnalyticsContextProvider]. +class AnalyticsContext { + final Map _data = {}; + AnalyticsContext? _parent; + + /// Creates a [AnalyticsContext] with event data [data]. + AnalyticsContext(Map data) { + _data.addAll(data); + } + + factory AnalyticsContext._withParent( + AnalyticsContext parent, + AnalyticsContext child, + ) => + AnalyticsContext( + Map.from(child._data), + ).._parent = parent; + + /// Recursively examines parent [AnalyticsContext]s, + /// and merges any data from them in to this [AnalyticsContext]'s data and returns the result. + Map resolve() => _parent != null + ? { + ..._parent!.resolve(), + ..._data, + } + : Map.from(_data); + + @override + operator ==(Object other) => other is AnalyticsContext + ? _parent == other._parent && mapEquals(_data, other._data) + : false; + + @override + int get hashCode => Object.hashAll([ + _parent, + const MapEquality().hash(_data), + ]); + + @override + String toString() => 'AnalyticsContext:${resolve()}'; +} + +class _MultiAnalyticsContext implements AnalyticsContext { + @override + AnalyticsContext? _parent; + + @override + Map get _data => { + for (var i in _globalContextMap.values) ...i.resolve(), + }; + + final _globalContextMap = {}; + + void add(Object key, AnalyticsContext value) { + _globalContextMap[key] = value; + } + + void remove(Object key) { + _globalContextMap.remove(key); + } + + void clear() { + _globalContextMap.clear(); + } + + AnalyticsContext? get(Object key) => _globalContextMap[key]; + + @override + Map resolve() => _data; +} + +/// A widget that provides [AnalyticsContext] to it's child widgets via [Provider]. +/// +/// example: +/// +/// ```dart +/// class MyWidget extends StatelessWidget { +/// const MyWidget({super.key}); +/// @override +/// Widget build(BuildContext context) { +/// return AnalyticsContextProvider( +/// analyticsContext: AnalyticsContext({ +/// 'hogehoge': 'fugafuga', +/// }), +/// child: ..., +/// ); +/// } +/// } +/// ``` +class AnalyticsContextProvider extends SingleChildStatelessWidget { + /// This [AnalyticsContext] is passed to child widgets via [Provider]. + final AnalyticsContext analyticsContext; + + /// A flag to reset [analyticsContext]. Default is `false.` + /// If set to true, a new analytics context will be created. + /// If set to false, it will merge with the existing analytics context. + final bool reset; + + /// Creates a AnalyticsContextProvider. + /// Provide an [analyticsContext] and pass the widget to be wrapped as [child]. + /// Optionally, use the [reset] flag to specify whether to reset the [analyticsContext]. + const AnalyticsContextProvider({ + Key? key, + required this.analyticsContext, + required Widget child, + this.reset = false, + }) : super( + key: key, + child: child, + ); + + @override + Widget buildWithChild(BuildContext context, Widget? child) { + final tContext = reset + ? analyticsContext + : AnalyticsContext._withParent( + context.watch(), + analyticsContext, + ); + + return _AnalyticsContextProviderRenderWidget( + analyticsContext: tContext, + child: Provider.value( + value: tContext, + child: child, + ), + ); + } +} + +class _AnalyticsContextProviderRenderWidget + extends SingleChildRenderObjectWidget { + final AnalyticsContext analyticsContext; + + const _AnalyticsContextProviderRenderWidget({ + Key? key, + required this.analyticsContext, + required Widget child, + }) : super( + key: key, + child: child, + ); + + @override + _AnalyticsContextProviderRenderObject createRenderObject( + BuildContext context) => + _AnalyticsContextProviderRenderObject(analyticsContext); + + @override + void updateRenderObject( + BuildContext context, + covariant _AnalyticsContextProviderRenderObject renderObject, + ) { + renderObject.analyticsContext = analyticsContext; + } +} + +class _AnalyticsContextProviderRenderObject extends RenderProxyBox { + AnalyticsContext analyticsContext; + + _AnalyticsContextProviderRenderObject(this.analyticsContext); +} + +/// Class that keeps track of which widgets the user has interacted with. +/// While this class is publicly accessible, it is not typically used directly by an application. +/// @nodoc +class AnalyticsPointerEventListener extends SingleChildRenderObjectWidget { + const AnalyticsPointerEventListener({ + Key? key, + required Widget child, + }) : super( + key: key, + child: child, + ); + + @override + RenderObject createRenderObject(BuildContext context) => + _AnalyticsPointerEventListenerRenderObject(context.read()); + + @override + void updateRenderObject( + BuildContext context, + // ignore: library_private_types_in_public_api + covariant _AnalyticsPointerEventListenerRenderObject renderObject, + ) { + renderObject.analytics = context.read(); + } +} + +class _AnalyticsPointerEventListenerRenderObject extends RenderProxyBox { + Analytics analytics; + + _AnalyticsPointerEventListenerRenderObject(this.analytics); + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (hitTestChildren(result, position: position)) { + AnalyticsContext? tContext; + RenderObject? tRenderObject; + + for (var i in result.path) { + if (i.target is RenderObject) { + tRenderObject = i.target as RenderObject; + + break; + } + } + + while (tRenderObject != null) { + if (tRenderObject is _AnalyticsContextProviderRenderObject) { + tContext = tRenderObject.analyticsContext; + + break; + } + + tRenderObject = tRenderObject.parent; + } + + analytics._interactionContext = tContext; + + return true; + } + + return false; + } + + // coverage:ignore-start + @override + bool hitTestSelf(Offset position) => false; + // coverage:ignore-end +} + +/// A widget that sends analytics events when the user taps the widget and releases their finger, +/// in other words, using [Listener.onPointerUp]. +class AnalyticsEventWidget extends StatelessWidget { + /// The name of the analytics event. + final String? name; + + /// Analytics data. + /// If [event] is not set, this property will be used. + final Map? data; + + /// The event to be set for analytics. + final AnalyticsEvent? event; + + /// Widgets to target for analytics. + final Widget? child; + + /// Creates a AnalyticsEventWidget. + /// Specify the widget targeted for analytics operations in [child] and send analytics data. + /// If you want to send a custom event, specify an AnalyticsEvent in [event]. If [event] is not set, + /// send analytics data with the analytics event name specified in [name]. + const AnalyticsEventWidget({ + Key? key, + this.name, + this.data, + this.event, + this.child, + }) : assert(event == null || name == null), + assert(data == null || event == null), + super(key: key); + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.deferToChild, + onPointerUp: (_) { + // ignore: todo + // TODO: This event fires even when the child event cancels... + if (event != null) { + context.read().rawEvent(event!); + } else { + context.read().event( + name: name!, + data: data, + context: context, + ); + } + }, + child: child, + ); + } +} + +/// A widget that sends an event only once when the child widget is built. +/// +/// example: +/// +/// ```dart +/// class MyWidget extends StatelessWidget { +/// const MyWidget({super.key}); +/// @override +/// Widget build(BuildContext context) { +/// return AnalyticsSingletonEventWidget( +/// name: 'Hogehoge' +/// child: ..., +/// ); +/// } +/// } +/// ``` +class AnalyticsSingletonEventWidget extends SingleChildStatefulWidget { + /// The name of the analytics event. + final String? name; + + /// Analytics data to be sent. + final Map? data; + + /// The event to be set for analytics. + final AnalyticsEvent? event; + + /// Creates a AnalyticsSingletonEventWidget. + const AnalyticsSingletonEventWidget({ + Key? key, + this.name, + this.data, + this.event, + Widget? child, + }) : assert(event == null || name == null), + assert(data == null || event == null), + super( + key: key, + child: child, + ); + + @override + // ignore: library_private_types_in_public_api + _AnalyticsSingletonEventWidgetState createState() => + _AnalyticsSingletonEventWidgetState(); +} + +class _AnalyticsSingletonEventWidgetState + extends SingleChildState { + @override + void initState() { + super.initState(); + _sendEvent(); + } + + @override + void didUpdateWidget(covariant AnalyticsSingletonEventWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + // Skip the data. + if (widget.event != oldWidget.event || widget.name != oldWidget.name) { + _sendEvent(); + } + } + + @override + Widget buildWithChild(BuildContext context, Widget? child) { + return child ?? const SizedBox.shrink(); + } + + void _sendEvent() { + if (widget.event != null) { + context.read().rawEvent(widget.event!); + } else { + context.read().event( + name: widget.name!, + data: widget.data, + context: context, + ); + } + } +} + +/// A widget that sends analytics when the widget is displayed, i.e., during impressions. +/// When using this widget, if you are adding this to each item in a list of items, it is recommended to set the [batchGenerator] for sending multiple analytics events with batch processing. +/// +/// example: +/// +/// ```dart +/// class AnalyticsHogehogePage extends StandardPage { +/// @override +/// Widget buildPage(BuildContext context) { +/// return ListView.builder( +/// itemCount: 50, +/// itemBuilder: (context, index) { +/// return AnalyticsContextProvider( +/// analyticsContext: AnalyticsContext({ +/// 'section': 'Section $index', +/// }), +/// child: AnalyticsImpressionWidget( +/// visibleThreshold: 0.1, +/// name: 'Analytics Impression', +/// data: { +/// 'name': 'Analytics Page Item $index', +/// }, +/// batchToIgnore: const {'section'}, +/// batchGenerator: (datas, contexts) { +/// return { +/// 'name': datas.map((e) => e['name']).join(','), +/// 'section': +/// contexts.map((e) => e.resolve()['section']).join(','), +/// }; +/// }, +/// child: Text("Impression index : $index"), +/// ), +/// ); +/// }, +/// ); +/// } +/// } +/// ``` +class AnalyticsImpressionWidget extends SingleChildStatefulWidget { + /// The time to wait after displaying before sending + final Duration durationThreshold; + + /// The value of what percentage of the child to be displayed before sending an event. + final double? visibleThreshold; + + /// This is a function that can be set when you want to use your custom logic instead of 'visibleThreshold' to determine whether it should be sent. + final bool Function(VisibilityInfo info)? thresholdCallback; + + /// The name of the analytics event. + final String? name; + + /// Analytics data. + final Map? data; + + /// The event to be set for analytics. + final AnalyticsEvent? event; + + /// Set to `true` to send events only once per lifecycle of this Widget, default is `false`. + final bool once; + + /// This function is used to create batch data. When set, it sends impression information as a batch. + final Map Function( + List> datas, List contexts)? + batchGenerator; + + /// A list of names of analytics context keys to ignore in batch sending. + final Set batchDataToIgnore; + + /// Creates a AnalyticsImpressionWidget. + const AnalyticsImpressionWidget({ + Key? key, + required Widget child, + this.name, + this.data, + this.event, + this.durationThreshold = const Duration( + seconds: 1, + ), + this.visibleThreshold, + this.thresholdCallback, + this.once = false, + this.batchGenerator, + this.batchDataToIgnore = const {}, + }) : assert( + (event == null && name != null) || (name == null && event != null)), + assert(data == null || event == null), + assert(visibleThreshold != null || thresholdCallback != null), + assert(!(event != null && batchGenerator != null)), + super( + key: key, + child: child, + ); + + @override + // ignore: library_private_types_in_public_api + _AnalyticsImpressionWidgetState createState() => + _AnalyticsImpressionWidgetState(); +} + +class _AnalyticsImpressionWidgetState + extends SingleChildState { + final _key = UniqueKey(); + bool _visible = false; + bool _eventSent = false; + Timer? _timer; + Map? _currentData; + + void _copyData() { + _eventSent = false; + _currentData = + widget.data == null ? null : Map.from(widget.data!); + } + + @override + void initState() { + super.initState(); + _copyData(); + } + + @override + void dispose() { + super.dispose(); + _timer?.cancel(); + VisibilityDetectorController.instance.forget(_key); + } + + @override + void didUpdateWidget(covariant AnalyticsImpressionWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + // Ignore the data. + if (widget.event != oldWidget.event || widget.name != oldWidget.name) { + _copyData(); + _schedule(); + } else { + notifyDataChanged(); + } + } + + @override + Widget buildWithChild(BuildContext context, Widget? child) { + return VisibilityDetector( + key: _key, + onVisibilityChanged: _onVisibilityChanged, + child: child!, + ); + } + + void notifyDataChanged() { + if (_visible && !mapEquals(_currentData, widget.data)) { + _copyData(); + _schedule(); + } + } + + void _onVisibilityChanged(VisibilityInfo info) { + if (widget.visibleThreshold != null) { + if (!_visible && info.visibleFraction >= widget.visibleThreshold!) { + _visible = true; + _schedule(); + } else if (_visible && info.visibleFraction < widget.visibleThreshold!) { + _visible = false; + _unschedule(); + } + } else if (widget.thresholdCallback != null) { + if (!_visible && widget.thresholdCallback!(info)) { + _visible = true; + _schedule(); + } else if (_visible && !widget.thresholdCallback!(info)) { + _visible = false; + _unschedule(); + } + } + } + + void _unschedule() { + _timer?.cancel(); + } + + void _schedule() { + if (!mounted || (widget.once && _eventSent)) { + return; + } + + _timer?.cancel(); + _timer = Timer(widget.durationThreshold, _sendEvent); + } + + static final _batches = {}; + static var _batchScheduled = false; + + int _batchHash( + Analytics analytics, + AnalyticsContext context, + String name, + Map Function( + List> datas, List contexts) + generator, + ) { + final tAnalyticsContext = context.resolve() + ..removeWhere((key, value) => widget.batchDataToIgnore.contains(key)); + + return Object.hash( + name, analytics, tAnalyticsContext.toString(), generator); + } + + void _scheduleBatch() { + if (_batchScheduled) { + return; + } + + _batchScheduled = true; + scheduleFunction(_processBatch); + } + + void _processBatch() { + _batchScheduled = false; + final tBatches = Map.of(_batches); + _batches.clear(); + + for (var i in tBatches.values) { + // Force ignored data to null. + final tData = widget.batchDataToIgnore.isNotEmpty + ? { + for (var i in widget.batchDataToIgnore) i: null, + ...i.generator(i.datas, i.contexts), + } + : i.generator(i.datas, i.contexts); + + i.analytics.rawEvent( + AnalyticsEvent( + name: i.name, + data: tData, + context: i.contexts.first, + ), + ); + } + } + + void _sendEvent() { + _eventSent = true; + final tAnalytics = context.read(); + + if (widget.batchGenerator != null) { + final tAnalyticsContext = context.read(); + + _batches.putIfAbsent( + _batchHash(tAnalytics, tAnalyticsContext, widget.name!, + widget.batchGenerator!), + () => _ImpressionBatch( + widget.name!, + tAnalytics, + widget.batchGenerator!, + ), + ) + ..datas.add(widget.data ?? const {}) + ..contexts.add(tAnalyticsContext); + _scheduleBatch(); + } else { + if (widget.event != null) { + tAnalytics.rawEvent(widget.event!); + } else { + tAnalytics.event( + name: widget.name!, + data: widget.data, + context: context, + ); + } + } + } +} + +class _ImpressionBatch { + final datas = >[]; + final contexts = []; + final String name; + final Analytics analytics; + final Map Function( + List> datas, List contexts) + generator; + + _ImpressionBatch( + this.name, + this.analytics, + this.generator, + ); +} + +/// A class representing the names of analytics events as strings. +class AnalyticsEventName { + /// The name of the `routeView` event. + static const String routeView = 'routeView'; + + /// The name of the `revenue` event. + static const String revenue = 'revenue'; +} + +/// A class representing the types of transitions as strings to be used as analytics data for page navigation. +class AnalyticsNavigationType { + /// The string for the `push` navigation type. + static const String push = 'push'; + + /// The string for the `pop` navigation type. + static const String pop = 'pop'; + + /// The string for the `replace` navigation type. + static const String replace = 'replace'; + + /// The string for the `remove` navigation type. + static const String remove = 'remove'; +} + +/// An extension to add [AnalyticsContext] functionality to [BuildContext]. +extension AnalyticsTryGetContext on BuildContext { + /// If [AnalyticsContext] can be retrieved from the widget tree, it returns that context. + AnalyticsContext? maybeGetAnalyticsContext() { + return Provider.of( + this, + listen: false, + ); + } +} + +/// Class for RouteView analytics events. +class AnalyticsRouteViewEvent extends AnalyticsEvent { + /// Flag indicating whether the route is the first (bottom most) route in the navigator's stack. + /// For more details, refer to [Route.isFirst]. + bool get isFirst => data!['isFirst'] as bool; + + /// Arguments passed to this route. + /// For more details, refer to [RouteSettings.arguments]. + String? get arguments => data!['arguments'] as String?; + + /// The name of this route. + /// For more details, refer to [RouteSettings.name]. + String? get routeName => data!['routeName'] as String?; + + /// A string representing the navigation type [AnalyticsNavigationType] during page transitions. + String get navigationType => data!['navigationType'] as String; + + /// Creates a AnalyticsRouteViewEvent + AnalyticsRouteViewEvent({ + required Analytics analytics, + required Route route, + required String navigationType, + }) : super( + name: AnalyticsEventName.routeView, + data: { + 'isFirst': route.isFirst, + if (route.settings.arguments != null) + 'arguments': route.settings.arguments?.toString(), + if (route.settings is StandardPageInterface) ...{ + 'pageData': (route.settings as StandardPageInterface) + .standardPageKey + .currentState != + null + ? (route.settings as StandardPageInterface) + .standardPageKey + .currentState! + .pageData + : route.settings.arguments, + 'pageLink': (route.settings as StandardPageInterface) + .standardPageKey + .currentState != + null + ? (route.settings as StandardPageInterface) + .standardPageKey + .currentState! + .link + : (route.settings as StandardPageInterface) + .factoryObject + .generateLink(route.settings.arguments), + ...Analytics.tryConvertToLoggableJsonParameters( + 'pageDataJson', + (route.settings as StandardPageInterface) + .standardPageKey + .currentState != + null + ? (route.settings as StandardPageInterface) + .standardPageKey + .currentState! + .pageData + : route.settings.arguments), + }, + if (route.settings.name != null) 'routeName': route.settings.name, + 'navigationType': navigationType, + }, + context: route.navigator?.context.maybeGetAnalyticsContext(), + ); +} + +/// The necessary NavigatorObserver for the analytics system to monitor page navigation. +/// While this class is publicly accessible, it is not typically used directly by an application. +/// @nodoc +class AnalyticsNavigatorObserver extends NavigatorObserver { + final Analytics _analytics; + + AnalyticsNavigatorObserver({ + required Analytics analytics, + }) : _analytics = analytics; + + @override + void didPush(Route route, Route? previousRoute) { + if (route.isActive && route.isCurrent) { + _logger.finer('AnalyticsNavigatorObserver:didPush'); + _analytics._globalRouteContext.clear(); + _analytics.routeViewEvent( + route, + navigationType: AnalyticsNavigationType.push, + ); + } + } + + @override + void didPop(Route route, Route? previousRoute) { + if (previousRoute == null) { + return; + } + + if (previousRoute.isActive && previousRoute.isCurrent) { + _logger.finer('AnalyticsNavigatorObserver:didPop'); + _analytics._globalRouteContext.clear(); + _analytics.routeViewEvent( + previousRoute, + navigationType: AnalyticsNavigationType.pop, + ); + } + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (previousRoute == null) { + return; + } + + if (previousRoute.isActive && previousRoute.isCurrent) { + _logger.finer('AnalyticsNavigatorObserver:didRemove'); + + _analytics._globalRouteContext.clear(); + _analytics.routeViewEvent( + previousRoute, + navigationType: AnalyticsNavigationType.remove, + ); + } + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + if (newRoute == null) { + return; + } + + _logger.finer('AnalyticsNavigatorObserver:didReplace'); + _analytics._globalRouteContext.clear(); + _analytics.routeViewEvent( + newRoute, + navigationType: AnalyticsNavigationType.replace, + ); + } + + // coverage:ignore-start + @override + void didStartUserGesture( + Route route, Route? previousRoute) { + _logger.finer('AnalyticsNavigatorObserver:didStartUserGesture'); + } + // coverage:ignore-end + + // coverage:ignore-start + @override + void didStopUserGesture() { + _logger.finer('AnalyticsNavigatorObserver:didStopUserGesture'); + } + // coverage:ignore-end +} + +/// Class for revenue-related analytics events. +class AnalyticsRevenueEvent extends AnalyticsEvent { + /// Constants for data keys related to revenue. + static const kDataKeyRevenue = 'revenue'; + + /// Constants for data keys related to currency. + static const kDataKeyCurrency = 'currency'; + + /// Constants for data keys related to Order ID. + static const kDataKeyOrderId = 'orderId'; + + /// Constants for data keys related to receipt. + static const kDataKeyReceipt = 'receipt'; + + /// Constants for data keys related to product ID. + static const kDataKeyProductId = 'productId'; + + /// Constants for data keys related to product name. + static const kDataKeyProductName = 'productName'; + + /// Value of revenue. + double get revenue => data![kDataKeyRevenue] as double; + + /// The name of the currency. This name is identified by the ISO 4217 currency code. + /// For more details, refer to [ISO Web](https://www.iso.org/iso-4217-currency-codes.html). + String? get currency => data![kDataKeyCurrency] as String?; + + /// Identifier for order. + String? get orderId => data![kDataKeyOrderId] as String?; + + /// Receipt after purchasing a product. + String? get receipt => data![kDataKeyReceipt] as String?; + + /// Identifier for products. + String? get productId => data![kDataKeyProductId] as String?; + + /// Name of the product. + String? get productName => data![kDataKeyProductName] as String?; + + /// Creates a AnalyticsRevenueEvent + AnalyticsRevenueEvent({ + required double revenue, + String? currency, + String? orderId, + String? receipt, + String? productId, + String? productName, + String? eventName, + Map? data, + super.context, + }) : super( + name: eventName ?? AnalyticsEventName.revenue, + data: { + kDataKeyRevenue: revenue, + if (currency?.isNotEmpty == true) kDataKeyCurrency: currency, + if (orderId?.isNotEmpty == true) kDataKeyOrderId: orderId, + if (receipt?.isNotEmpty == true) kDataKeyReceipt: receipt, + if (productId?.isNotEmpty == true) kDataKeyProductId: productId, + if (productName?.isNotEmpty == true) + kDataKeyProductName: productName, + }..addAll(data ?? const {}), + ); +} diff --git a/packages/patapata_core/lib/src/app.dart b/packages/patapata_core/lib/src/app.dart new file mode 100644 index 0000000..222ed35 --- /dev/null +++ b/packages/patapata_core/lib/src/app.dart @@ -0,0 +1,924 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:patapata_core/patapata_core.dart'; +import 'package:provider/provider.dart'; + +final _logger = Logger('patapata.App'); + +const _methodChannel = MethodChannel('dev.patapata.patapata_core'); + +/// A value to check if this is running in a test environment. +/// Defined by setting the dart define IS_TEST to true +/// +/// Example: flutter test --dart-define=IS_TEST=true +const bool kIsTest = bool.fromEnvironment('IS_TEST', defaultValue: false); + +/// Defines what stage the [App] is currently in. +enum AppStage { + /// The first stage where the [App] hasn't done any operations + /// and [App.run] hasn't been executed yet. + setup, + + /// Entered after [App.run] executes. + /// Right after changing, the [App.run]'s bootstrapCallback parameter is executed. + /// At this stage, Flutter's services has been initialized via [WidgetsFlutterBinding.ensureInitialized], + /// all code is from now on executed in a guarded [Zone] to catch errors, + /// and the logging system for [App.log] is set up. + bootstrap, + + /// All [Plugin]s that are able to initialize (all requirements are met) + /// are initialized in this stage. + initializingPlugins, + + /// [RemoteConfig] systems from [Plugin.createRemoteConfig] are created + /// and initialized in this stage. + setupRemoteConfig, + + /// [Plugin]s that required a [RemoteConfig] to be set up before initialization + /// are initialized in this stage. + /// It is in this stage where [Plugin]s that should be disabled via [RemoteConfig] + /// are removed. + initializingPluginsWithRemoteConfig, + + /// The main stage of an [App]. After initialization, the [App] will generally be + /// in this stage forever except during rare cases or tests. + running, + + /// Entered upon calling [App.dispose]. + /// In general, this does not happen except during rare cases or tests. + disposed, +} + +/// Returns the [App] for the current [Zone]. +/// In almost all cases in your application, you can use this to +/// grab your [App] instance without a [BuildContext] +/// [T] is not required and will work undefined (as dynamic). +App getApp() => ((kDebugMode && !kIsTest) + ? (Zone.current[#patapataApp] ?? // coverage:ignore-line + _debugAppZone[#patapataApp]) // coverage:ignore-line + : Zone.current[#patapataApp]) as App; + +// https://github.com/flutter/flutter/issues/93676 +late Zone _debugAppZone; // coverage:ignore-line + +/// The main class for a Patapata app. +/// Typically passed to [runApp]. +/// The main class for a Patapata-based application. +class App { + static final StreamController _stageStreamController = + StreamController.broadcast(sync: true); + + /// A [Stream] that can be listened to for global changes to all [App]s that exist. + static Stream get appStageChangeStream => _stageStreamController.stream; + + /// These are [Plugin]s required for the core of Patapata to work. + final _defaultRequiredPlugins = [ + I18nPlugin(), + NativeLocalConfigPlugin(), + NetworkPlugin(), + PackageInfoPlugin(), + DeviceInfoPlugin(), + NotificationsPlugin(), + StandardAppPlugin(), + ]; + + AppStage __stage = AppStage.setup; + + /// The current stage of this [App]. + AppStage get stage => __stage; + set _stage(AppStage value) { + assert(__stage != AppStage.disposed); + + __stage = value; + _logger.fine('Stage ${value.name}'); + _stageStreamController.add(this); + } + + /// The Environment object that will be accessible to all + /// Widgets via a [Provider]. + /// This is also used to customize [Plugin]s usually via a Mixin. + final T environment; + + /// A helper function to attempt to cast [environment] as the given Type [V]. + /// If [environment] is not a [V] this function returns null. + /// Useful for a [Plugin] to check an Environment supports that [Plugin]s Mixin features. + V? environmentAs() => environment is V ? environment as V : null; + + /// Use this to access all the logging features of Patapata. + late final Log log; + + /// A function that can create a custom [User] object or subclass for this [App]. + /// This is usually used to provide custom functions, properties, login methods + /// and such for your own application. + /// + /// For example: + /// ```dart + /// + /// class MyCustomUser extends User { + /// MyCustomUser({super.app}); + /// + /// void login() async { + /// // Contact your server to login. + /// changeId(await _apiLogin()); + /// } + /// } + /// + /// void main() { + /// App( + /// userFactory: (app) => MyCustomUser(app: app), + /// ).run(); + /// } + /// ``` + final User Function(App app) userFactory; + + static User _sDefaultUserFactory(App app) => User(app: app); + + User? _user; + + /// Gets the current [User] and if not yet created, creates one + /// via [userFactory]. If there is no [userFactory] set, creates + /// a default [User]. + User get user => _user ??= userFactory(this); + + final Analytics _analytics = Analytics(); + + /// Use this to access the [Analytics] system for this [App]. + Analytics get analytics => _analytics; + + late final Permissions _permissions = Permissions(app: this); + + /// Use this to access the [Permissions] system for this [App]. + Permissions get permissions => _permissions; + + /// The [Widget] ultimately passed in to flutter's [runApp] function. + /// If you are using the [StandardAppPlugin], you usually return a + /// [StandardMaterialApp] or [StandardCupertinoApp] from this function. + final Widget Function(BuildContext context, App app) createAppWidget; + + /// The error widget to show in a non-debug build when + /// a [FlutterError] occurrs. + /// Default is to show nothing `const SizedBox.shrink()`. + final ErrorWidgetBuilder nonDebugErrorWidgetBuilder; + ErrorWidgetBuilder? _originalErrorWidgetBuilder; + + /// A callback that gets called when this [App] fails to initialize at any stage. + final FutureOr Function(T, dynamic, StackTrace)? onInitFailure; + + final List _plugins = []; + + /// Access to the [RemoteConfig] system. + /// This particular [RemoteConfig] acts as a proxy between + /// all of the [RemoteConfig]s created from all initialized [Plugin]s. + /// + /// When getting a value from this, it will attempt to get the first value + /// that exists from the given key amongst the registered [RemoteConfig]s in [Plugin] + /// registration order. ie, the order that [Plugin]s were passed in to the plugins + /// parameter in the constuctor of this [App] appending anything added via [addPlugin] afterwards. + /// + /// The [RemoteConfig.fetch] called from this [RemoteConfig] is guaranteed to succeed, + /// ignoring all exceptions. + final RemoteConfig remoteConfig = ProxyRemoteConfig(); + ProxyRemoteConfig get _proxyRemoteConfig => remoteConfig as ProxyRemoteConfig; + final Map _pluginToRemoteConfigMap = {}; + + /// Access to the [LocalConfig] system. + /// This particular [LocalConfig] acts as a proxy between + /// all of the [LocalConfig]s created from all initialized [Plugin]s. + /// + /// When getting a value from this, it will attempt to get the first value + /// that exists from the given key amongst the registered [LocalConfig]s in [Plugin] + /// registration order. ie, the order that [Plugin]s were passed in to the plugins + /// parameter in the constuctor of this [App] appending anything added via [addPlugin] afterwards. + /// + /// If no [Plugin]s support [LocalConfig], a default in-memory [LocalConfig] is used with no persistence. + final LocalConfig localConfig = ProxyLocalConfig(); + ProxyLocalConfig get _proxyLocalConfig => localConfig as ProxyLocalConfig; + final Map _pluginToLocalConfigMap = {}; + + /// Access to the [RemoteMessaging] system. + /// This particular [RemoteMessaging] acts as a proxy between + /// all of the [RemoteMessaging]s created from all initialized [Plugin]s. + /// + /// For any get-style methods like [RemoteMessaging.getToken] or [RemoteMessaging.getInitialMessage], + /// only the first registered [RemoteMessaging] in [Plugin] registration order's value is returned. + /// All other methods execute on all registered [RemoteMessaging]. + /// All streams are joined to be accessed via one stream from this object. + final RemoteMessaging remoteMessaging = ProxyRemoteMessaging(); + ProxyRemoteMessaging get _proxyRemoteMessaging => + remoteMessaging as ProxyRemoteMessaging; + final Map _pluginToRemoteMessagingMap = {}; + + /// Access to the [StartupSequence] system. + /// If the app is rendering widgets using the [StandardAppPlugin] system, + /// [StartupSequence.resetMachine] is automatically executed only once when the app is launched. + final StartupSequence? startupSequence; + + bool _loadedFakeNow = false; + + late final Zone _appZone; + + /// Runs [func] in this [App]s error [Zone]. + /// Usually you do not need to use this. However in cases + /// where you are executing code that was in or forked from the + /// root [Zone] you may want to execute your code via this + /// function to allow for Patapata to catch errors, logs, etc correctly. + Future runProcess(FutureOr Function() func) { + final tCompleter = Completer(); + + _appZone.run>(() async { + try { + final tResult = await func(); + tCompleter.complete(tResult); + } catch (error, stackTrace) { + tCompleter.completeError(error, stackTrace); + rethrow; + } + }); + + return tCompleter.future; + } + + final GlobalKey _providerKey; + final bool _customProviderKey; + + /// All providers registered inside of + /// Patapata are accessible from with function, along with all + /// providers above the providerKey given in this [App]'s constructor if provided. + R getProvider() => _providerKey.currentContext!.read(); + + /// Creates a new Patapata [App]. + /// This is generally created in your main.dart files main function. + /// + /// In a normal Flutter application, you will pass your application's main + /// [Widget] in to [runApp]. + /// + /// With Patapata, you create this [App], passing [createAppWidget] which should return + /// a [Widget]. This [Widget] will eventually get passed in to [runApp] after executing [run]. + /// + /// [environment] is a [T] that you use to customize the Environment of your application. + /// [Plugin]s use this object with Mixins to customize how a [Plugin] may behave. + /// + /// Example using [LogEnvironment] to customize the logging system: + /// ```dart + /// class Environment with LogLevelEnvironment { + /// @override + /// final int logLevel; + /// + /// @override + /// final bool printLog; + /// + /// const Environment({ + /// required this.logLevel, + /// required this.printLog, + /// }); + /// } + /// + /// void main() { + /// App({ + /// environment: const Environment({ + /// // Take the value from --dart-define + /// logLevel: int.fromEnvironment('LOG_LEVEL'), + /// printLog: bool.fromEnvironment('PRINT_LOG'), + /// }), + /// }); + /// } + /// ``` + /// + /// [providerKey] can be passed here to declare your own location in the + /// widget hiearchy to allow for adding your own [MultiProvider] or single [Provider] + /// for your own applications purposes. + /// One set, you can use [getProvider] to get access to these providers without access + /// to a [BuildContext]. + /// + /// Example using [StandardAppPlugin] + /// ```dart + /// final _providerKey = GlobalKey(); + /// + /// void main() { + /// App({ + /// providerKey: _providerKey, + /// createAppWidget: (context, app) => StandardMaterialApp( + /// pages: [], + /// routableBuilder: (context, child) => MultiProvider( + /// providers: [ + /// Provider( + /// create: (context) => ClassA(), + /// ), + /// Provider.value( + /// value: context.read() as MyCustomUser, + /// ), + /// ], + /// child: KeyedSubtree( + /// key: _providerKey, + /// child: child, + /// ), + /// ), + /// }); + /// } + /// + /// // (In a different Widget somewhere in your app) + /// getApp().getProvider(); + /// ``` + /// + /// [userFactory] is used to create customized [User] objects. + /// + /// [nonDebugErrorWidgetBuilder] is used to customize the [Widget] + /// shown in place of the standard 'red screen of death' that Flutter + /// shows. Only active in release builds and defaults to a [SizedBox.shrink]. + /// + /// [onInitFailure] will be executed when this [App] fails to run for some reason. + /// + /// [plugins] is a list of [Plugin] instances that you want to enable in this [App]. + App({ + required this.environment, + required this.createAppWidget, + GlobalKey? providerKey, + this.userFactory = _sDefaultUserFactory, + this.nonDebugErrorWidgetBuilder = _defaultNonDebugErrorWidgetBuilder, + this.onInitFailure, + this.startupSequence, + Iterable? plugins, + }) : _customProviderKey = providerKey != null, + _providerKey = + providerKey ?? GlobalKey(debugLabel: 'patapata.App.providerKey') { + _plugins.addAll(_defaultRequiredPlugins); + + if (kIsTest) { + // ignore: invalid_use_of_visible_for_testing_member + permissions.setMockMethodCallHandler(); + } + + if (plugins != null) { + _plugins.addAll(plugins); + } + } + + // coverage:ignore-start + /// The default for non-debug builds is to get rid of the + /// red screen of death. + static Widget _defaultNonDebugErrorWidgetBuilder( + FlutterErrorDetails details) { + return const SizedBox.shrink(); + } + // coverage:ignore-end + + Future _initializePlugin(Plugin plugin) async { + _logger.fine('initializePlugin start: ${plugin.name}'); + + // Initialize. If it returns false, that means the plugin + // wants to silently not enable itself. + if (!await plugin.init(this)) { + _logger.fine('initializePlugin silent fail: ${plugin.name}'); + + return false; + } + + // Register any [LocalConfig]. + final tLocalConfig = plugin.createLocalConfig(); + + if (tLocalConfig != null) { + await _proxyLocalConfig.addLocalConfig(tLocalConfig); + _pluginToLocalConfigMap[plugin] = tLocalConfig; + } + + // Register any [RemoteConfig]. + final tRemoteConfig = plugin.createRemoteConfig(); + + if (tRemoteConfig != null) { + await _proxyRemoteConfig.addRemoteConfig(tRemoteConfig); + _pluginToRemoteConfigMap[plugin] = tRemoteConfig; + } + + // Register any [RemoteMessaging]. + final tRemoteMessaging = plugin.createRemoteMessaging(); + + if (tRemoteMessaging != null) { + await _proxyRemoteMessaging.addRemoteMessaging(tRemoteMessaging); + _pluginToRemoteMessagingMap[plugin] = tRemoteMessaging; + } + + if (!kIsTest) { + // coverage:ignore-start + await _methodChannel.invokeMethod('enablePlugin', plugin.name); + // coverage:ignore-end + } else { + // ignore: invalid_use_of_visible_for_testing_member + plugin.mockPatapataEnable(); + } + + _logger.fine('initializePlugin done: ${plugin.name}'); + + return true; + } + + Future _disposePlugin(Plugin plugin) async { + _logger.fine('disposePlugin start: ${plugin.name}'); + + // Unregister any [RemoteMessaging] + if (_pluginToRemoteMessagingMap.containsKey(plugin)) { + _proxyRemoteMessaging + .removeRemoteMessaging(_pluginToRemoteMessagingMap.remove(plugin)!); + } + + // Unregister any [RemoteConfig] + if (_pluginToRemoteConfigMap.containsKey(plugin)) { + _proxyRemoteConfig + .removeRemoteConfig(_pluginToRemoteConfigMap.remove(plugin)!); + } + + // Unregister any [LocalConfig] + if (_pluginToLocalConfigMap.containsKey(plugin)) { + _proxyLocalConfig + .removeLocalConfig(_pluginToLocalConfigMap.remove(plugin)!); + } + + if (!kIsTest) { + // coverage:ignore-start + await _methodChannel.invokeMethod('disablePlugin', plugin.name); + // coverage:ignore-end + } else { + // ignore: invalid_use_of_visible_for_testing_member + plugin.mockPatapataDisable(); + } + + // Dispose of it + await plugin.dispose(); + + _logger.fine('disposePlugin done: ${plugin.name}'); + } + + /// Adds a plugin to this [App]. + /// + /// The [plugin] will be added to the list of active plugins. + Future addPlugin(Plugin plugin) async { + if (stage.index > AppStage.initializingPluginsWithRemoteConfig.index) { + try { + if (await _initializePlugin(plugin)) { + _plugins.add(plugin); + } + } catch (error) { + // Failed to initialize this plugin at runtime. + // We don't fail the entire app in this case. + // However, we do allow the error system to find out + // what's going on so we rethrow. + rethrow; + } + } else { + _plugins.add(plugin); + } + } + + /// Removes a plugin from this [App]. + Future removePlugin(Plugin plugin) async { + if (!_plugins.remove(plugin)) { + return; + } + + if (!plugin.initialized) { + return; + } + + if (!plugin.disposed) { + try { + await _disposePlugin(plugin); + } catch (error) { + // Allow the error system to find out + // what's going on so we rethrow. + rethrow; + } + } + } + + /// Returns true if the given [Plugin] type is currently registered. + bool hasPlugin(Type type) => _plugins.any((p) => p.runtimeType == type); + + /// Attempts to get the given [P] [Plugin] or null if it doesn't exist. + P? getPlugin

() => _plugins.firstWhereOrNull((p) => p is P) as P?; + + /// Gets a list of [Plugin]s of type [P]. + /// This is usually used to look for [Plugin]s that have a specific Mixin + /// to apply features to them. + List

getPluginsOfType

() => _plugins.whereType

().toList(); + + /// Access to information about the network. + NetworkPlugin get network { + final tNetworkPlugin = getPlugin(); + assert(tNetworkPlugin != null, + 'Default required plugin NetworkPlugin removed.'); + + return tNetworkPlugin!; + } + + /// Access to information about this application's metadata. + PackageInfoPlugin get package { + final tPackageInfoPlugin = getPlugin(); + assert(tPackageInfoPlugin != null, + 'Default required plugin PackageInfoPlugin removed.'); + + return tPackageInfoPlugin!; + } + + /// Access to information about the device that this application is running on. + DeviceInfoPlugin get device { + final tDeviceInfoPlugin = getPlugin(); + assert(tDeviceInfoPlugin != null, + 'Default required plugin DeviceInfoPlugin removed.'); + + return tDeviceInfoPlugin!; + } + + void _onRemoteConfigChange() { + _updateLogLevel(); + } + + void _updateLogLevel() { + log.setLevelByValue(remoteConfig.getInt( + 'patapata_log_level', + defaultValue: -kPataInHex, + )); + } + + late final StartupNavigatorObserver _startupNavigatorObserver = + StartupNavigatorObserver(startupSequence: startupSequence!); + + /// A list of all [NavigatorObserver]s to use in a [Navigator] + /// from all the [Plugin]s registered to this [App]. + /// The [Analytics] and [StartupSequence] system also relies on setting these to any + /// [Navigator]s in the application. + List get navigatorObservers => [ + for (var v in _plugins) ...v.navigatorObservers, + AnalyticsNavigatorObserver(analytics: analytics), + if (startupSequence != null) _startupNavigatorObserver, + ]; + + static const _forceRemoveNativeSplashScreenDuration = + Duration(milliseconds: 5000); + Timer? _forceRemoveNativeSplashScreenTimer; + bool _removedNativeSplashScreen = false; + + /// Remove the native splash screen for this application. + Future removeNativeSplashScreen() async { + if (_removedNativeSplashScreen) { + return; + } + _removedNativeSplashScreen = true; + + if (_forceRemoveNativeSplashScreenTimer?.isActive == true) { + // The timer does not work in the test environment. + // coverage:ignore-start + _forceRemoveNativeSplashScreenTimer?.cancel(); + _forceRemoveNativeSplashScreenTimer = null; + // coverage:ignore-end + } + + // When invokeMethod fails to find the platform plugin, it returns null + // instead of throwing an exception. + await const OptionalMethodChannel('plugin/splash_screen') + .invokeMethod('removeNativeSplashScreen'); + } + + /// Run the app. + /// Returns true if the app was successfully run. + /// + /// This will actually start up Flutter (via [runApp]) and + /// begin running through each of the [AppStage]s. + /// Pass [boostrapCallback] to run a callback after Flutter services are initialized + /// and logging systems are initialized but before anything else during the [AppStage.bootstrap] stage. + Future run([FutureOr Function()? bootstrapCallback]) async { + final tCompleter = Completer(); + + try { + _stageStreamController.add(this); + + // Execute in a guarded Zone to catch all errors correctly. + // This also allows [getApp] to work as we set a zone value for it here. + runZonedGuarded>( + () async { + try { + if (kDebugMode && !kIsTest) { + // coverage:ignore-start + _debugAppZone = Zone.current; + // coverage:ignore-end + } + bool tIsDebug = false; + assert(tIsDebug = true); + + _appZone = Zone.current; + + // Before we do anything, + // make sure the WidgetBindings are up and running so we can execute all of Flutter's APIs. + WidgetsFlutterBinding.ensureInitialized(); + + if (kIsTest) { + removeNativeSplashScreen(); + } else { + // coverage:ignore-start + _forceRemoveNativeSplashScreenTimer ??= Timer( + _forceRemoveNativeSplashScreenDuration, + removeNativeSplashScreen); + // coverage:ignore-end + } + + log = Log(this); + + if (!tIsDebug) { + // coverage:ignore-start + _originalErrorWidgetBuilder = ErrorWidget.builder; + ErrorWidget.builder = nonDebugErrorWidgetBuilder; + // coverage:ignore-end + } + + _stage = AppStage.bootstrap; + + if (bootstrapCallback != null) { + await bootstrapCallback(); + } + + _stage = AppStage.initializingPlugins; + + await _proxyRemoteMessaging.init(this); + + // Initialize plugins that don't require RemoteConfig + final tPluginsCopy = _plugins.toList(growable: false); + + for (var tPlugin in tPluginsCopy) { + if (!tPlugin.requireRemoteConfig) { + if (!await _initializePlugin(tPlugin)) { + _plugins.remove(tPlugin); + } + } + } + + _stage = AppStage.setupRemoteConfig; + + // Update RemoteConfigs + await remoteConfig.init(); + // Allow this fetch to fail like for offline startups. + remoteConfig.addListener(_onRemoteConfigChange); + await remoteConfig + .fetch() + .timeout(const Duration(seconds: 2), onTimeout: () {}); + + _stage = AppStage.initializingPluginsWithRemoteConfig; + + // Now we can remotely disable plugins that need to be. + // Initialize plugins that do require RemoteConfig. + // And remove plugins that should be disabled. + final tPlugins = _plugins.toList(growable: false); + + for (var tPlugin in tPlugins) { + if (!remoteConfig.getBool(tPlugin.remoteConfigEnabledKey, + defaultValue: true)) { + // Remotely disabled, remove it without initializing. + await removePlugin(tPlugin); + + continue; + } + + if (!tPlugin.initialized) { + if (!await _initializePlugin(tPlugin)) { + // Failed or rejected to initialize, remove it. + await removePlugin(tPlugin); + } + } + } + + _stage = AppStage.running; + + runApp( + MultiProvider( + providers: [ + Provider.value( + value: this, + ), + Provider>.value( + value: this, + ), + Provider.value( + value: environment, + ), + ChangeNotifierProvider.value( + value: user, + ), + ChangeNotifierProvider.value( + value: remoteConfig, + ), + ChangeNotifierProvider.value( + value: localConfig, + ), + ChangeNotifierProvider.value( + value: remoteMessaging, + ), + Provider.value( + value: analytics, + ), + Provider.value( + value: analytics.globalContext, + ), + ], + child: AnalyticsPointerEventListener( + child: (() { + Widget tChild = Builder( + builder: (context) { + // Load fake time now and only once. + if (!_loadedFakeNow) { + _loadedFakeNow = true; + loadFakeNow(); + } + + return createAppWidget(context, this); + }, + ); + + if (!_customProviderKey) { + tChild = KeyedSubtree( + key: _providerKey, + child: tChild, + ); + } + + for (var tPlugin in _plugins.reversed) { + tChild = tPlugin.createAppWidgetWrapper(tChild); + } + + return tChild; + })(), + ), + ), + ); + + tCompleter.complete(true); + } catch (e, stackTrace) { + if (onInitFailure != null) { + await onInitFailure!(environment, e, stackTrace); + } else { + // ignore: avoid_print + print(e); + // ignore: avoid_print + print(stackTrace); + } + + tCompleter.complete(false); + // Throw to runZonedGuarded onError. Processing takes place in the App zone. + rethrow; + } + }, + (error, stackTrace) async { + removeNativeSplashScreen(); + + if (kDebugMode) { + // In debug mode, always print errors. + debugPrint(error.toString()); + + try { + debugPrintStack(stackTrace: stackTrace); + } catch (e) { + // coverage:ignore-start + // Sometimes debugPrintStack can't print custom stack traces + debugPrint(stackTrace.toString()); + // coverage:ignore-end + } + } + + final tLevel = (error is PatapataException) + ? (error.logLevel != null && error.logLevel! > Level.SEVERE) + ? error.logLevel! + : Level.SEVERE + : Level.SEVERE; + log.report( + ReportRecord( + level: tLevel, + error: error, + stackTrace: stackTrace, + fingerprint: + (error is PatapataException) ? error.fingerprint : null, + mechanism: Log.kUnhandledErrorMechanism, + ), + ); + }, + zoneValues: { + #patapataApp: this, + }, + ); + } catch (e, stackTrace) { + // coverage:ignore-start + // We don't care at this point. + // It is possible that AppStage initialization failed or + // runZonedGuarded itself failed to execute. + if (onInitFailure != null) { + await onInitFailure!(environment, e, stackTrace); + } else { + // ignore: avoid_print + print(e); + // ignore: avoid_print + print(stackTrace); + } + tCompleter.complete(false); + // coverage:ignore-end + } + + return tCompleter.future; + } + + /// Should be called when you want to get rid of this [App] + /// and all resources associated with it, including static callbacks. + /// Not usually needed in a real app, but used mostly in test tearDown. + void dispose() { + _stage = AppStage.disposed; + + _forceRemoveNativeSplashScreenTimer?.cancel(); + _forceRemoveNativeSplashScreenTimer = null; + + if (_originalErrorWidgetBuilder != null) { + // coverage:ignore-start + ErrorWidget.builder = _originalErrorWidgetBuilder!; + _originalErrorWidgetBuilder = null; + // coverage:ignore-end + } + + try { + user.dispose(); + } catch (e, stackTrace) { + // coverage:ignore-start + // We don't care at this point. + // Some other libraries may have already disposed of the user. + _logger.fine('Failed to dispose user', e, stackTrace); + // coverage:ignore-end + } + _user = null; + + // Remove all plugins. + final tPlugins = _plugins.toList(growable: false); + for (var i in tPlugins) { + try { + removePlugin(i); + } catch (e, stackTrace) { + // coverage:ignore-start + // We don't care at this point. + // Just try to remove the next one. + _logger.fine('Failed to remove plugin $i', e, stackTrace); + // coverage:ignore-end + } + } + + try { + remoteConfig.dispose(); + } catch (e, stackTrace) { + // coverage:ignore-start + // We don't care at this point. + // Some other libraries may have already disposed of the remoteConfig. + _logger.fine('Failed to dispose remoteConfig', e, stackTrace); + // coverage:ignore-end + } + + try { + localConfig.dispose(); + } catch (e, stackTrace) { + // coverage:ignore-start + // We don't care at this point. + // Some other libraries may have already disposed of the localConfig. + _logger.fine('Failed to dispose localConfig', e, stackTrace); + // coverage:ignore-end + } + + try { + remoteMessaging.dispose(); + } catch (e, stackTrace) { + // coverage:ignore-start + // We don't care at this point. + // Some other libraries may have already disposed of the remoteMessaging. + _logger.fine('Failed to dispose remoteMessaging', e, stackTrace); + // coverage:ignore-end + } + + try { + permissions.dispose(); + } catch (e, stackTrace) { + // coverage:ignore-start + // We don't care at this point. + // Some other libraries may have already disposed of the permissions. + _logger.fine('Failed to dispose permissions', e, stackTrace); + // coverage:ignore-end + } + + try { + log.dispose(); + } catch (e, stackTrace) { + // coverage:ignore-start + // We don't care at this point. + // Some other libraries may have already disposed of the log. + _logger.fine('Failed to dispose log', e, stackTrace); + // coverage:ignore-end + } + } +} diff --git a/packages/patapata_core/lib/src/config.dart b/packages/patapata_core/lib/src/config.dart new file mode 100644 index 0000000..11ce520 --- /dev/null +++ b/packages/patapata_core/lib/src/config.dart @@ -0,0 +1,140 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:patapata_core/patapata_core.dart'; + +/// A base class that manages the application's Config and +/// provides the functionality to notify changes. +/// +/// When the value of Config is changed, +/// please call [onChange] on the implementation side. +/// +/// See also: +/// +/// [RemoteConfig], [LocalConfig] +abstract class Config extends ChangeNotifier with MethodChannelTestMixin { + /// Default value for configuration items of type String. + /// If a configuration item of type String is not set, this value will be used. + static const defaultValueForString = ''; + + /// Default value for configuration items of type Boolean. + /// If a configuration item of type Boolean is not set, this value will be used. + static const defaultValueForBool = false; + + /// Default value for configuration items of type Integer. + /// If a configuration item of type Integer is not set, this value will be used. + static const defaultValueForInt = 0; + + /// Default value for configuration items of type Double. + /// If a configuration item of type Double is not set, this value will be used. + static const defaultValueForDouble = 0.0; + + bool _initialized = false; + + /// Indicates whether the configuration has been initialized. + bool get initialized => _initialized; + + bool _disposed = false; + + /// Indicates whether the configuration has been disposed. + bool get disposed => _disposed; + + /// Initializes the configuration. + /// + /// If the application is in test mode, it sets the mock method call handler. + /// After initialization, the configuration is marked as initialized and not disposed. + @mustCallSuper + Future init() async { + if (kIsTest) { + setMockMethodCallHandler(); + } + + _initialized = true; + _disposed = false; + } + + /// Disposes the configuration. + /// + /// After disposal, the configuration is marked as not initialized and disposed. + @override + @mustCallSuper + void dispose() { + super.dispose(); + _initialized = false; + _disposed = true; + } + + /// The value of Config has been changed. + @protected + void onChange() { + notifyListeners(); + } +} + +/// Provides an interface to read values from [Config]. +mixin ReadableConfig on Config { + /// Checks if the given [key] exists in the configuration. + bool hasKey(String key); + + /// Returns the value corresponding to the given [key] as a String. + String getString( + String key, { + String defaultValue = Config.defaultValueForString, + }); + + /// Returns the value corresponding to the given [key] as a Boolean. + bool getBool( + String key, { + bool defaultValue = Config.defaultValueForBool, + }); + + /// Returns the value corresponding to the given [key] as an Integer. + int getInt( + String key, { + int defaultValue = Config.defaultValueForInt, + }); + + /// Returns the value corresponding to the given [key] as a Double. + double getDouble( + String key, { + double defaultValue = Config.defaultValueForDouble, + }); + + /// Sets the default values for the configuration. + /// + /// The [defaults] parameter is a map containing the default key-value pairs. + /// These values are used when no other values are specified for the corresponding keys. + Future setDefaults(Map defaults); +} + +/// Provides an interface to write values to [Config]. +mixin WritableConfig on Config { + /// Sets the value of the given [key] to the specified String [value]. + Future setString(String key, String value); + + /// Sets the value of the given [key] to the specified Boolean [value]. + Future setBool(String key, bool value); + + /// Sets the value of the given [key] to the specified Integer [value]. + Future setInt(String key, int value); + + /// Sets the value of the given [key] to the specified Double [value]. + Future setDouble(String key, double value); + + /// Sets the values of multiple keys at once. + /// + /// The [objects] parameter is a map where each key-value pair represents a configuration item. + Future setMany(Map objects); + + /// Resets all configuration items to their default values. + Future resetAll(); + + /// Resets the configuration item corresponding to the given [key] to its default value. + Future reset(String key); + + /// Resets the configuration items corresponding to the given list of [keys] to their default values. + Future resetMany(List keys); +} diff --git a/packages/patapata_core/lib/src/device_info.dart b/packages/patapata_core/lib/src/device_info.dart new file mode 100644 index 0000000..25f6d11 --- /dev/null +++ b/packages/patapata_core/lib/src/device_info.dart @@ -0,0 +1,598 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +// ignore: depend_on_referenced_packages +import 'package:device_info_plus_platform_interface/device_info_plus_platform_interface.dart'; +import 'package:device_info_plus/device_info_plus.dart' as plugin; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import 'plugin.dart'; +import 'app.dart'; +import 'util.dart'; + +/// Plugin that manages information related to the platform's device. +/// +/// This plugin retrieves information from [DeviceInfoPlatform] during initialization, +/// allowing synchronous access from the application or other plugins. +/// +/// This plugin is automatically created during application initialization +/// and can be accessed from [App.package]. +class DeviceInfoPlugin extends Plugin { + final plugin.DeviceInfoPlugin _deviceInfoPlugin = plugin.DeviceInfoPlugin(); + + plugin.AndroidDeviceInfo? _androidDeviceInfo; + + /// device information for Android. + plugin.AndroidDeviceInfo? get androidDeviceInfo => _androidDeviceInfo; + + plugin.IosDeviceInfo? _iosDeviceInfo; + + /// device information for iOS. + plugin.IosDeviceInfo? get iosDeviceInfo => _iosDeviceInfo; + + plugin.LinuxDeviceInfo? _linuxInfo; + + /// device information for Linux. + plugin.LinuxDeviceInfo? get linuxInfo => _linuxInfo; + + plugin.MacOsDeviceInfo? _macOsInfo; + + /// device information for macOS. + plugin.MacOsDeviceInfo? get macOsInfo => _macOsInfo; + + plugin.WindowsDeviceInfo? _windowsInfo; + + /// device information for Windows. + plugin.WindowsDeviceInfo? get windowsInfo => _windowsInfo; + + plugin.WebBrowserInfo? _webInfo; + + /// device information for WebBrowser. + plugin.WebBrowserInfo? get webInfo => _webInfo; + + @override + FutureOr init(App app) async { + await super.init(app); + + if (debugIsWeb || kIsWeb) { + _webInfo = await _deviceInfoPlugin.webBrowserInfo; + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + _androidDeviceInfo = await _deviceInfoPlugin.androidInfo; + break; + case TargetPlatform.iOS: + _iosDeviceInfo = await _deviceInfoPlugin.iosInfo; + break; + case TargetPlatform.linux: + _linuxInfo = await _deviceInfoPlugin.linuxInfo; + break; + case TargetPlatform.macOS: + _macOsInfo = await _deviceInfoPlugin.macOsInfo; + break; + case TargetPlatform.windows: + _windowsInfo = await _deviceInfoPlugin.windowsInfo; + break; + default: + break; + } + } + + return true; + } + + @override + Widget createAppWidgetWrapper(Widget child) { + return Provider.value( + value: this, + child: child, + ); + } + + /// If your test environment is [TargetPlatform.linux], [TargetPlatform.windows], Web, + /// then it assumes that you want to override and specify [DeviceInfoPlatform.instance], + /// so I'm not considering entering a branch. + /// + /// The functions used to overwrite [DeviceInfoPlatform] in each environment are: + /// [TargetPlatform.linux] ... [DeviceInfoLinuxPlatform.registerWith] + /// [TargetPlatform.windows] ... [DeviceInfoWindowsPlatform.registerWith] + /// Web ... [DeviceInfoWebPlatform.registerWith] + /// Check the lib/test/device_info_test.dart for usage examples. + @override + @visibleForTesting + void setMockMethodCallHandler() { + // ignore: invalid_use_of_visible_for_testing_member + testSetMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/device_info'), + (methodCall) async { + methodCallLogs.add(methodCall); + switch (methodCall.method) { + case 'getDeviceInfo': + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return mockAndroidDeviceInfoMap; + case TargetPlatform.iOS: + return mockIosDeviceInfoMap; + case TargetPlatform.macOS: + return mockMacosDeviceInfoMap; + default: + break; + } + } + return null; + }, + ); + } + + // Declared in the same order as the source code + // of [AndroidDeviceInfo.fromMap] to make it easier to compare. + static const Map _defaultMockAndroidDeviceInfoMap = + { + 'version': { + 'sdkInt': 16, + 'baseOS': 'baseOS', + 'previewSdkInt': 30, + 'release': 'release', + 'codename': 'codename', + 'incremental': 'incremental', + 'securityPatch': 'securityPatch', + }, + 'board': 'board', + 'bootloader': 'bootloader', + 'brand': 'Google', + 'device': 'device', + 'display': 'display', + 'fingerprint': 'fingerprint', + 'hardware': 'hardware', + 'host': 'host', + 'id': 'id', + 'manufacturer': 'manufacturer', + 'model': 'model', + 'product': 'product', + 'supported32BitAbis': ['x86 (IA-32)', 'MMX'], + 'supported64BitAbis': ['x86-64', 'MMX', 'SSSE3'], + 'supportedAbis': ['arm64-v8a', 'x86', 'x86_64'], + 'tags': 'tags', + 'type': 'type', + 'isPhysicalDevice': true, + 'systemFeatures': ['FEATURE_AUDIO_PRO', 'FEATURE_AUDIO_OUTPUT'], + 'displayMetrics': { + 'widthPx': 1080.0, + 'heightPx': 2220.0, + 'xDpi': 530.0859, + 'yDpi': 529.4639, + }, + 'serialNumber': 'serialNumber', + }; + + @visibleForTesting + static Map mockAndroidDeviceInfoMap = + _defaultMockAndroidDeviceInfoMap; + + /// Mocks [AndroidDeviceInfo] for testing purposes. + @visibleForTesting + static void setMockAndroidDeviceInfo({ + String? id, + String? host, + String? tags, + String? type, + String? model, + String? board, + String? brand, + String? device, + String? product, + String? display, + String? hardware, + bool? isPhysicalDevice, + String? bootloader, + String? fingerprint, + String? manufacturer, + List? supportedAbis, + List? systemFeatures, + Map? version, + List? supported64BitAbis, + List? supported32BitAbis, + Map? displayMetrics, + String? serialNumber, + }) { + mockAndroidDeviceInfoMap = { + 'id': id ?? _defaultMockAndroidDeviceInfoMap['id'], + 'host': host ?? _defaultMockAndroidDeviceInfoMap['host'], + 'tags': tags ?? _defaultMockAndroidDeviceInfoMap['tags'], + 'type': type ?? _defaultMockAndroidDeviceInfoMap['type'], + 'model': model ?? _defaultMockAndroidDeviceInfoMap['model'], + 'board': board ?? _defaultMockAndroidDeviceInfoMap['board'], + 'brand': brand ?? _defaultMockAndroidDeviceInfoMap['brand'], + 'device': device ?? _defaultMockAndroidDeviceInfoMap['device'], + 'product': product ?? _defaultMockAndroidDeviceInfoMap['product'], + 'display': display ?? _defaultMockAndroidDeviceInfoMap['display'], + 'hardware': hardware ?? _defaultMockAndroidDeviceInfoMap['hardware'], + 'isPhysicalDevice': isPhysicalDevice ?? + _defaultMockAndroidDeviceInfoMap['isPhysicalDevice'], + 'bootloader': + bootloader ?? _defaultMockAndroidDeviceInfoMap['bootloader'], + 'fingerprint': + fingerprint ?? _defaultMockAndroidDeviceInfoMap['fingerprint'], + 'manufacturer': + manufacturer ?? _defaultMockAndroidDeviceInfoMap['manufacturer'], + 'supportedAbis': + supportedAbis ?? _defaultMockAndroidDeviceInfoMap['supportedAbis'], + 'systemFeatures': + systemFeatures ?? _defaultMockAndroidDeviceInfoMap['systemFeatures'], + 'version': version ?? _defaultMockAndroidDeviceInfoMap['version'], + 'supported64BitAbis': supported64BitAbis ?? + _defaultMockAndroidDeviceInfoMap['supported64BitAbis'], + 'supported32BitAbis': supported32BitAbis ?? + _defaultMockAndroidDeviceInfoMap['supported32BitAbis'], + 'displayMetrics': + displayMetrics ?? _defaultMockAndroidDeviceInfoMap['displayMetrics'], + 'serialNumber': + serialNumber ?? _defaultMockAndroidDeviceInfoMap['serialNumber'], + }; + } + + @visibleForTesting + static Map mockIosDeviceInfoMap = { + 'name': 'name', + 'model': 'model', + 'utsname': { + 'release': 'release', + 'version': 'version', + 'machine': 'machine', + 'sysname': 'sysname', + 'nodename': 'nodename', + }, + 'systemName': 'systemName', + 'systemVersion': 'systemVersion', + 'isPhysicalDevice': 'true', + 'localizedModel': 'localizedModel', + 'identifierForVendor': 'identifierForVendor', + }; + + /// Mocks [IosDeviceInfo] for testing purposes. + @visibleForTesting + static void setMockIosDeviceInfo({ + String? name, + String? model, + Map? utsname, + String? systemName, + String? systemVersion, + bool? isPhysicalDevice, + String? localizedModel, + String? identifierForVendor, + }) { + mockIosDeviceInfoMap = { + 'name': name ?? mockIosDeviceInfoMap['name'], + 'model': model ?? mockIosDeviceInfoMap['model'], + 'utsname': utsname ?? mockIosDeviceInfoMap['utsname'], + 'systemName': systemName ?? mockIosDeviceInfoMap['systemName'], + 'systemVersion': systemVersion ?? mockIosDeviceInfoMap['systemVersion'], + 'isPhysicalDevice': isPhysicalDevice?.toString() ?? + mockIosDeviceInfoMap['isPhysicalDevice'], + 'localizedModel': + localizedModel ?? mockIosDeviceInfoMap['localizedModel'], + 'identifierForVendor': + identifierForVendor ?? mockIosDeviceInfoMap['identifierForVendor'], + }; + } + + @visibleForTesting + static plugin.LinuxDeviceInfo mockLinuxDeviceInfo = plugin.LinuxDeviceInfo( + name: 'name', + version: 'version', + id: 'id', + idLike: ['idLike'], + versionCodename: 'versionCodename', + versionId: 'versionId', + prettyName: 'prettyName', + buildId: 'buildId', + variant: 'variant', + variantId: 'variantId', + machineId: 'machineId', + ); + + /// Mocks [LinuxDeviceInfo] for testing purposes. + @visibleForTesting + static void setMockLinuxDeviceInfo({ + String? name, + String? version, + String? id, + List? idLike, + String? versionCodename, + String? versionId, + String? prettyName, + String? buildId, + String? variant, + String? variantId, + String? machineId, + }) { + mockLinuxDeviceInfo = plugin.LinuxDeviceInfo( + name: name ?? mockLinuxDeviceInfo.name, + version: version ?? mockLinuxDeviceInfo.version, + id: id ?? mockLinuxDeviceInfo.id, + idLike: idLike ?? mockLinuxDeviceInfo.idLike, + versionCodename: versionCodename ?? mockLinuxDeviceInfo.versionCodename, + versionId: versionId ?? mockLinuxDeviceInfo.versionId, + prettyName: prettyName ?? mockLinuxDeviceInfo.prettyName, + buildId: buildId ?? mockLinuxDeviceInfo.buildId, + variant: variant ?? mockLinuxDeviceInfo.variant, + variantId: variantId ?? mockLinuxDeviceInfo.variantId, + machineId: machineId ?? mockLinuxDeviceInfo.machineId, + ); + } + + @visibleForTesting + static Map mockMacosDeviceInfoMap = { + 'computerName': 'computerName', + 'hostName': 'hostName', + 'arch': 'arch', + 'model': 'model', + 'kernelVersion': 'kernelVersion', + 'osRelease': 'osRelease', + 'majorVersion': 10, + 'minorVersion': 9, + 'patchVersion': 3, + 'activeCPUs': 4, + 'memorySize': 16, + 'cpuFrequency': 2, + 'systemGUID': 'systemGUID', + }; + + /// Mocks [MacOsDeviceInfo] for testing purposes. + @visibleForTesting + static void setMockMacosDeviceInfo({ + String? arch, + String? model, + int? activeCPUs, + int? memorySize, + int? cpuFrequency, + String? hostName, + String? osRelease, + String? computerName, + String? kernelVersion, + String? systemGUID, + int? majorVersion, + int? minorVersion, + int? patchVersion, + }) { + mockMacosDeviceInfoMap = { + 'arch': arch ?? mockMacosDeviceInfoMap['arch'], + 'model': model ?? mockMacosDeviceInfoMap['model'], + 'activeCPUs': activeCPUs ?? mockMacosDeviceInfoMap['activeCPUs'], + 'memorySize': memorySize ?? mockMacosDeviceInfoMap['memorySize'], + 'cpuFrequency': cpuFrequency ?? mockMacosDeviceInfoMap['cpuFrequency'], + 'hostName': hostName ?? mockMacosDeviceInfoMap['hostName'], + 'osRelease': osRelease ?? mockMacosDeviceInfoMap['osRelease'], + 'computerName': computerName ?? mockMacosDeviceInfoMap['computerName'], + 'kernelVersion': kernelVersion ?? mockMacosDeviceInfoMap['kernelVersion'], + 'systemGUID': systemGUID ?? mockMacosDeviceInfoMap['systemGUID'], + 'majorVersion': majorVersion ?? mockMacosDeviceInfoMap['majorVersion'], + 'minorVersion': minorVersion ?? mockMacosDeviceInfoMap['minorVersion'], + 'patchVersion': patchVersion ?? mockMacosDeviceInfoMap['patchVersion'], + }; + } + + @visibleForTesting + static plugin.WindowsDeviceInfo mockWindowsDeviceInfo = + plugin.WindowsDeviceInfo( + computerName: 'computerName', + numberOfCores: 4, + systemMemoryInMegabytes: 16, + userName: 'userName', + majorVersion: 10, + minorVersion: 0, + buildNumber: 10240, + platformId: 1, + csdVersion: 'csdVersion', + servicePackMajor: 1, + servicePackMinor: 0, + suitMask: 1, + productType: 1, + reserved: 1, + buildLab: '22000.co_release.210604-1628', + buildLabEx: '22000.1.amd64fre.co_release.210604-1628', + digitalProductId: Uint8List.fromList([]), + displayVersion: '21H2', + editionId: 'Pro', + installDate: DateTime(2022, 04, 02), + productId: '00000-00000-0000-AAAAA', + productName: 'Windows 10 Pro', + registeredOwner: 'registeredOwner', + releaseId: 'releaseId', + deviceId: 'deviceId', + ); + + /// Mocks [WindowsDeviceInfo] for testing purposes. + @visibleForTesting + static void setMockWindowsDeviceInfo({ + String? computerName, + int? numberOfCores, + int? systemMemoryInMegabytes, + String? userName, + int? majorVersion, + int? minorVersion, + int? buildNumber, + int? platformId, + String? csdVersion, + int? servicePackMajor, + int? servicePackMinor, + int? suitMask, + int? productType, + int? reserved, + String? buildLab, + String? buildLabEx, + Uint8List? digitalProductId, + String? displayVersion, + String? editionId, + DateTime? installDate, + String? productId, + String? productName, + String? registeredOwner, + String? releaseId, + String? deviceId, + }) { + mockWindowsDeviceInfo = plugin.WindowsDeviceInfo( + computerName: computerName ?? mockWindowsDeviceInfo.computerName, + numberOfCores: numberOfCores ?? mockWindowsDeviceInfo.numberOfCores, + systemMemoryInMegabytes: systemMemoryInMegabytes ?? + mockWindowsDeviceInfo.systemMemoryInMegabytes, + userName: userName ?? mockWindowsDeviceInfo.userName, + majorVersion: majorVersion ?? mockWindowsDeviceInfo.majorVersion, + minorVersion: minorVersion ?? mockWindowsDeviceInfo.minorVersion, + buildNumber: buildNumber ?? mockWindowsDeviceInfo.buildNumber, + platformId: platformId ?? mockWindowsDeviceInfo.platformId, + csdVersion: csdVersion ?? mockWindowsDeviceInfo.csdVersion, + servicePackMajor: + servicePackMajor ?? mockWindowsDeviceInfo.servicePackMajor, + servicePackMinor: + servicePackMinor ?? mockWindowsDeviceInfo.servicePackMinor, + suitMask: suitMask ?? mockWindowsDeviceInfo.suitMask, + productType: productType ?? mockWindowsDeviceInfo.productType, + reserved: reserved ?? mockWindowsDeviceInfo.reserved, + buildLab: buildLab ?? mockWindowsDeviceInfo.buildLab, + buildLabEx: buildLabEx ?? mockWindowsDeviceInfo.buildLabEx, + digitalProductId: + digitalProductId ?? mockWindowsDeviceInfo.digitalProductId, + displayVersion: displayVersion ?? mockWindowsDeviceInfo.displayVersion, + editionId: editionId ?? mockWindowsDeviceInfo.editionId, + installDate: installDate ?? mockWindowsDeviceInfo.installDate, + productId: productId ?? mockWindowsDeviceInfo.productId, + productName: productName ?? mockWindowsDeviceInfo.productName, + registeredOwner: registeredOwner ?? mockWindowsDeviceInfo.registeredOwner, + releaseId: releaseId ?? mockWindowsDeviceInfo.releaseId, + deviceId: deviceId ?? mockWindowsDeviceInfo.deviceId, + ); + } + + @visibleForTesting + static Map mockWebBrowserInfoMap = { + 'browserName': plugin.BrowserName.safari, + 'appCodeName': 'appCodeName', + 'appName': 'appName', + 'appVersion': 'appVersion', + 'deviceMemory': 42, + 'language': 'language', + 'languages': ['en', 'es'], + 'platform': 'platform', + 'product': 'product', + 'productSub': 'productSub', + 'userAgent': 'Safari', + 'vendor': 'vendor', + 'vendorSub': 'vendorSub', + 'hardwareConcurrency': 2, + 'maxTouchPoints': 42, + }; + + /// Mocks [WebBrowserInfo] for testing purposes. + @visibleForTesting + static void setMockWebBrowserInfo({ + String? browserName, + String? appCodeName, + String? appName, + String? appVersion, + int? deviceMemory, + String? language, + List? languages, + String? platform, + String? product, + String? productSub, + String? userAgent, + String? vendor, + String? vendorSub, + int? hardwareConcurrency, + int? maxTouchPoints, + }) { + mockWebBrowserInfoMap = { + 'browserName': + browserName?.toBrowserName ?? mockWebBrowserInfoMap['browserName'], + 'appCodeName': appCodeName ?? mockWebBrowserInfoMap['appCodeName'], + 'appName': appName ?? mockWebBrowserInfoMap['appName'], + 'appVersion': appVersion ?? mockWebBrowserInfoMap['appVersion'], + 'deviceMemory': deviceMemory ?? mockWebBrowserInfoMap['deviceMemory'], + 'language': language ?? mockWebBrowserInfoMap['language'], + 'languages': languages ?? mockWebBrowserInfoMap['languages'], + 'platform': platform ?? mockWebBrowserInfoMap['platform'], + 'product': product ?? mockWebBrowserInfoMap['product'], + 'productSub': productSub ?? mockWebBrowserInfoMap['productSub'], + 'userAgent': userAgent ?? mockWebBrowserInfoMap['userAgent'], + 'vendor': vendor ?? mockWebBrowserInfoMap['vendor'], + 'vendorSub': vendorSub ?? mockWebBrowserInfoMap['vendorSub'], + 'hardwareConcurrency': + hardwareConcurrency ?? mockWebBrowserInfoMap['hardwareConcurrency'], + 'maxTouchPoints': + maxTouchPoints ?? mockWebBrowserInfoMap['maxTouchPoints'], + }; + } +} + +@visibleForTesting +bool debugIsWeb = false; + +extension DeviceInfoStringExtension on String { + plugin.BrowserName get toBrowserName { + switch (toLowerCase()) { + case 'firefox': + return plugin.BrowserName.firefox; + case 'samsunginternet': + return plugin.BrowserName.samsungInternet; + case 'opera': + return plugin.BrowserName.opera; + case 'msie': + return plugin.BrowserName.msie; + case 'edge': + return plugin.BrowserName.edge; + case 'chrome': + return plugin.BrowserName.chrome; + case 'safari': + return plugin.BrowserName.safari; + default: + return plugin.BrowserName.unknown; + } + } +} + +@visibleForTesting +class DeviceInfoLinuxPlatform extends DeviceInfoPlatform { + static void registerWith() { + DeviceInfoPlatform.instance = DeviceInfoLinuxPlatform(); + } + + @override + Future deviceInfo() async { + return DeviceInfoPlugin.mockLinuxDeviceInfo; + } +} + +@visibleForTesting +class DeviceInfoWindowsPlatform extends DeviceInfoPlatform { + static void registerWith() { + DeviceInfoPlatform.instance = DeviceInfoWindowsPlatform(); + } + + @override + Future deviceInfo() async { + return DeviceInfoPlugin.mockWindowsDeviceInfo; + } +} + +@visibleForTesting +class DeviceInfoWebPlatform extends DeviceInfoPlatform { + static void registerWith() { + DeviceInfoPlatform.instance = DeviceInfoWebPlatform(); + } + + @override + Future deviceInfo() async { + return plugin.WebBrowserInfo.fromMap( + DeviceInfoPlugin.mockWebBrowserInfoMap, + ); + } +} diff --git a/packages/patapata_core/lib/src/error.dart b/packages/patapata_core/lib/src/error.dart new file mode 100644 index 0000000..256c7d3 --- /dev/null +++ b/packages/patapata_core/lib/src/error.dart @@ -0,0 +1,296 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/widgets.dart'; + +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +/// Settings for [PatapataException] +mixin ErrorEnvironment { + /// Overrides the value of [PatapataException.prefix] by [PatapataException.namespace]. + /// + /// example: + /// When the [PatapataException.namespace] is defined as `patapata`, + /// This would override the value of [PatapataException.prefix] to `APE`. + /// ```dart + /// { + /// 'patapata': 'APE' + /// } + /// ``` + Map? get errorReplacePrefixMap; + + /// Defines the default [PatapataException.widget]. + Widget Function(PatapataException)? get errorDefaultWidget; + + /// Defines the default behavior of [PatapataException.showDialog]. + Future Function(BuildContext, PatapataException)? + get errorDefaultShowDialog; +} + +/// Defines exceptions that occur in applications using patapata. +abstract class PatapataException { + final App? __app; + App? get _app => __app ?? (Zone.current[#patapataApp] as App?); + + /// Error message. + /// This message is for logging and debugging purposes and is not meant to be displayed to the user. + final String? message; + + /// Original error. + final Object? original; + + /// Used to group and deduplicate events with the same fingerprint in the [Log] system. + final List? fingerprint; + + /// Parameters passed in the localization of [localizedTitle]. + /// [prefix] is passed by default. + /// + /// example: [namespace] is `patapata` and [internalCode] is `000`, then + /// + /// dart + /// ```dart + /// { + /// 'body': 'Title', + /// } + /// ``` + /// yaml + /// ```yaml + /// errors: + /// patapata: + /// '000': + /// title: {body}: {prefix}000 + /// ``` + final Map? localeTitleData; + + /// Parameters passed in the localization of [localizedMessage]. + /// [prefix] is passed by default. + /// + /// example: [namespace] is `patapata` and [internalCode] is `000`, then + /// + /// dart + /// ```dart + /// { + /// 'body': 'Message', + /// } + /// ``` + /// yaml + /// ```yaml + /// errors: + /// patapata: + /// '000': + /// message: {body}: {prefix}000 + /// ``` + final Map? localeMessageData; + + /// Parameters passed in the localization of [localizedFix]. + /// [prefix] is passed by default. + /// + /// example: [namespace] is `patapata` and [internalCode] is `000`, then + /// + /// dart + /// ```dart + /// { + /// 'body': 'Fix', + /// } + /// ``` + /// yaml + /// ```yaml + /// errors: + /// patapata: + /// '000': + /// fix: {body}: {prefix}000 + /// ``` + final Map? localeFixData; + + const PatapataException({ + App? app, + this.message, + this.original, + this.fingerprint, + this.localeTitleData, + this.localeMessageData, + this.localeFixData, + this.fix, + this.logLevel, + Level? userLogLevel, + }) : __app = app, + _userLogLevel = userLogLevel; + + /// Prefix for [code] + /// Can be set by [ErrorEnvironment.errorReplacePrefixMap] with [namespace]. + /// If not set, then [defaultPrefix]. + /// + /// Used as a localization key when being localized like in [localizedMessage]. + String get prefix => + _environment?.errorReplacePrefixMap?[namespace] ?? defaultPrefix; + + /// Error code. + /// Usually a string of [prefix] and [internalCode]. + String get code => '$prefix$internalCode'; + + /// Default [prefix] when not set in [ErrorEnvironment.errorReplacePrefixMap]. + @protected + String get defaultPrefix; + + /// Internal error code. + /// Used as a localization key when being localized like in [localizedMessage]. + @protected + String get internalCode; + + /// Defines the namespace of the error. + /// Used in localization keys like in [localizedMessage] and settings in [ErrorEnvironment]. + String get namespace; + + /// You can define a process to recover from the error. + final Future Function()? fix; + + /// true if [fix] is defined. + bool get hasFix => fix != null; + + /// Displays the error title localized by the [L10n] system. + /// + /// The localization key will be `'errors.$namespace.$internalCode.title'`. + /// + /// example: [namespace] is `patapata` and [internalCode] is `000`, then + /// ```yaml + /// errors: + /// patapata: + /// '000': + /// title: ErrorTitle + /// ``` + String get localizedTitle => hasLocalizedTitle + ? _l10n!.lookup( + _localizeTitleKey, + namedParameters: { + 'prefix': prefix, + ...(localeTitleData ?? {}), + }, + ) + : _defaultTitleLocalize; + + /// Displays the error message localized by the [L10n] system. + /// + /// The localization key will be `'errors.$namespace.$internalCode.message'`. + /// + /// example: [namespace] is `patapata` and [internalCode] is `000`, then + /// ```yaml + /// errors: + /// patapata: + /// '000': + /// message: ErrorMessage + /// ``` + String get localizedMessage => hasLocalizedMessage + ? _l10n!.lookup( + _localizeMessageKey, + namedParameters: { + 'prefix': prefix, + ...(localeMessageData ?? {}), + }, + ) + : _defaultMessageLocalize; + + /// Displays the error treatment message localized by the [L10n] system. + /// + /// The localization key will be `'errors.$namespace.$internalCode.fix'`. + /// + /// example: [namespace] is `patapata` and [internalCode] is `000`, then + /// ```yaml + /// errors: + /// patapata: + /// '000': + /// fix: ErrorFix + /// ``` + String get localizedFix => hasLocalizedFix + ? _l10n!.lookup( + _localizeFixKey, + namedParameters: { + 'prefix': prefix, + ...(localeFixData ?? {}), + }, + ) + : _defaultFixLocalize; + + /// true if the localization key for [localizedTitle] is defined. + bool get hasLocalizedTitle => + _l10n?.containsMessageKey(_localizeTitleKey) ?? false; + + /// true if the localization key for [localizedMessage] is defined. + bool get hasLocalizedMessage => + _l10n?.containsMessageKey(_localizeMessageKey) ?? false; + + /// true if the localization key for [localizedFix] is defined. + bool get hasLocalizedFix => + _l10n?.containsMessageKey(_localizeFixKey) ?? false; + + /// Returns ErrorWidget. + /// + /// Return the widget set in [ErrorEnvironment.errorDefaultWidget]. + /// If not set, it returns [Text] displaying [localizedMessage]. + Widget get widget => + _environment?.errorDefaultWidget?.call(this) ?? Text(localizedMessage); + + /// Notification [Level] to the [Log] system. + final Level? logLevel; + + /// Importance from the user's perspective. + /// + /// Even if [logLevel] is [Level.INFO], if it's a significant error that warrants displaying an error page, + /// setting this to [Level.SHOUT] will navigate to the error page during [onReported]. + /// Note that this value is not relayed to the [Log] system, and instead, the value of [logLevel] is reported. + Level? get userLogLevel => _userLogLevel ?? logLevel; + + final Level? _userLogLevel; + + /// Displays a dialog. + /// + /// By default, it calls [PlatformDialog.show] displaying [localizedTitle] and [localizedMessage]. + /// This can be customized by [ErrorEnvironment.errorDefaultShowDialog]. + Future showDialog(BuildContext context) => + _environment?.errorDefaultShowDialog?.call(context, this) ?? + PlatformDialog.show( + context: context, + title: localizedTitle, + message: localizedMessage, + actions: [ + PlatformDialogAction( + result: () => true, + text: 'OK', + ), + ], + ); + + /// Called when this exception is reported by the [Log] system. + /// + /// If using the [StandardAppPlugin] system and [userLogLevel] == [Level.SHOUT], + /// it navigates to the page defined in [StandardErrorPageFactory]. + void onReported(ReportRecord record) { + if (userLogLevel == Level.SHOUT) { + _app?.getPlugin()?.delegate?.goErrorPage(record); + } + } + + String get _localizeTitleKey => 'errors.$namespace.$internalCode.title'; + String get _localizeMessageKey => 'errors.$namespace.$internalCode.message'; + String get _localizeFixKey => 'errors.$namespace.$internalCode.fix'; + + String get _defaultTitleLocalize => 'Error: $code'; + String get _defaultMessageLocalize => '$runtimeType: code=$code'; + String get _defaultFixLocalize => 'OK'; + + L10n? get _l10n => _app?.getPlugin()?.i18n.delegate.l10n; + + /// The current environment. + /// If the current environment is of type [ErrorEnvironment], it is returned, otherwise null is returned. + ErrorEnvironment? get _environment => _app?.environment is ErrorEnvironment + ? _app!.environment as ErrorEnvironment + : null; + + @override + String toString() { + return '$runtimeType: code=$code, message=$message${original != null ? ', original=$original' : ''}'; + } +} diff --git a/packages/patapata_core/lib/src/exception.dart b/packages/patapata_core/lib/src/exception.dart new file mode 100644 index 0000000..f008bd0 --- /dev/null +++ b/packages/patapata_core/lib/src/exception.dart @@ -0,0 +1,83 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +// ignore_for_file: constant_identifier_names + +import 'package:patapata_core/patapata_core.dart'; + +/// Error codes for [PatapataCoreException]. +enum PatapataCoreExceptionCode { + /// [LogicStateTransitionNotFound] + PPE101, + + /// [LogicStateTransitionNotAllowed] + PPE102, + + /// [LogicStateAllTransitionsNotAllowed] + PPE103, + + /// [LogicStateNotCurrent] + PPE104, + + /// [ConflictException] + PPE201, + + /// [ResetStartupSequence] + PPE301, + + /// [L10nLoadAssetsException] + PPE401, + + /// [NotificationsInitializationException] + PPE501, +} + +/// Extension to split [PatapataCoreExceptionCode] into +/// [PatapataCoreException.prefix] and [PatapataCoreException.internalCode]. +extension PatapataCoreExceptionCodeExtension on PatapataCoreExceptionCode { + /// Prefix of the error code. + String get prefix => name.substring(0, 3); + + /// Internal code of the error code. + String get internalCode => name.substring(3); +} + +/// `patapata_core` package exception. +/// +/// This exception is thrown when an error occurs in the `patapata_core` package. +/// The error code is defined in [PatapataCoreExceptionCode]. +/// +/// [defaultPrefix] is `PPE`. ([PatapataCoreExceptionCodeExtension.prefix]) +/// [namespace] is `patapata` +/// +/// The value of [prefix] can be overridden by defining +/// [ErrorEnvironment.errorReplacePrefixMap] on the application side. +/// If it is not defined, the value of [defaultPrefix] will be used. +abstract class PatapataCoreException extends PatapataException { + final PatapataCoreExceptionCode _exceptionCode; + + const PatapataCoreException({ + required PatapataCoreExceptionCode code, + super.app, + super.message, + super.original, + super.fingerprint, + super.localeTitleData, + super.localeMessageData, + super.localeFixData, + super.fix, + super.logLevel, + super.userLogLevel, + }) : _exceptionCode = code; + + @override + String get defaultPrefix => _exceptionCode.prefix; + + @override + String get internalCode => _exceptionCode.internalCode; + + @override + String get namespace => 'patapata'; +} diff --git a/packages/patapata_core/lib/src/fake_ffi.dart b/packages/patapata_core/lib/src/fake_ffi.dart new file mode 100644 index 0000000..eec5ce7 --- /dev/null +++ b/packages/patapata_core/lib/src/fake_ffi.dart @@ -0,0 +1,13 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +/// This file is for the sole purpose of providing an empty ffi shell for +/// the web. Just to compile things. Not execute. + +class Pointer {} + +class Struct {} + +class Union {} diff --git a/packages/patapata_core/lib/src/i18n.dart b/packages/patapata_core/lib/src/i18n.dart new file mode 100644 index 0000000..8f513f6 --- /dev/null +++ b/packages/patapata_core/lib/src/i18n.dart @@ -0,0 +1,410 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/message_format.dart'; +import 'package:logging/logging.dart'; +import 'package:patapata_core/src/exception.dart'; +import 'package:patapata_core/src/util.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:yaml/yaml.dart'; + +import 'app.dart'; +import 'plugin.dart'; + +final _logger = Logger('patapata.I18n'); + +bool _reassembling = false; + +/// Language settings of the application. Used in [I18nPlugin]. +mixin I18nEnvironment { + /// List of [Locale] supported by the application (default is `Locale('en')`) + List? get supportedL10ns; + + /// List of paths to directories storing language files (default is `l10n`) + List? get l10nPaths; +} + +/// Returns a localized string using the yaml file when [L10n.lookup] is called. +/// +/// Specify the key set in the yaml file for [key]. +/// If the key does not exist, it will return the [key] string. +/// +/// By passing parameters to [namedParameters], formatting compliant with +/// [MessageFormat] is possible. +/// +/// example: +/// +/// yaml +/// ```yaml +/// example: +/// title: Title +/// ``` +/// dart +/// ```dart +/// l(context, 'example.title'); +/// ``` +String l( + BuildContext context, + String key, [ + Map? namedParameters, +]) => + Localizations.of(context, L10n)?.lookup( + key, + namedParameters: namedParameters, + ) ?? + key; + +/// Provides functionality required to switch the language of the application. +/// +/// This class is automatically created during the initialization of +/// [I18nPlugin] and can be retrieved from the Plugin. +/// +/// By setting [supportedL10ns] and [l10nDelegates] to [WidgetsApp]'s +/// `supportedLocales` and `localizationsDelegates` respectively, a +/// [Localizations] Widget containing the [L10n] suitable for the +/// language in use will be added to the tree. +/// +/// If using [StandardMaterialApp] or [StandardCupertinoApp], +/// it will be set automatically. +class I18n { + final List _l10nPaths = []; + + final List _supportedL10ns = []; + + /// The `supportedLocales` to be set in [WidgetsApp]. + List get supportedL10ns => _supportedL10ns; + + /// The [LocalizationsDelegate] provided by patapata. + late final L10nDelegate delegate = L10nDelegate(this); + + /// The `localizationsDelegates` to be set in [WidgetsApp]. + late final List l10nDelegates = [ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; +} + +/// Plugin that provides localization. +/// The [l] function becomes available. +/// +/// This Plugin is required for the core of Patapata to work. +/// Automatically initialized during the initialization of patapata [App]. +class I18nPlugin extends Plugin { + final _i18n = I18n(); + + /// The [I18n] class. + I18n get i18n => _i18n; + + @override + FutureOr init(App app) async { + await super.init(app); + + tz.initializeTimeZones(); + + final tEnv = app.environment; + if (tEnv is I18nEnvironment) { + final tPaths = tEnv.l10nPaths; + + if (tPaths != null) { + _i18n._l10nPaths.addAll(tPaths); + } else { + _i18n._l10nPaths.add('l10n'); + } + + final tSupported = tEnv.supportedL10ns; + + if (tSupported != null) { + _i18n._supportedL10ns.addAll(tSupported); + } else { + return false; + } + } else { + _i18n._l10nPaths.add('l10n'); + _i18n._supportedL10ns.add(const Locale('en')); + } + + return true; + } + + @override + Widget createAppWidgetWrapper(Widget child) => _L10nAssetReloader( + child: child, + ); +} + +class _L10nAssetReloader extends StatefulWidget { + final Widget child; + + const _L10nAssetReloader({ + Key? key, + required this.child, + }) : super(key: key); + + @override + State<_L10nAssetReloader> createState() => __L10nAssetReloaderState(); +} + +class __L10nAssetReloaderState extends State<_L10nAssetReloader> { + @override + Widget build(BuildContext context) { + return widget.child; + } + + // coverage:ignore-start + @override + void reassemble() { + super.reassemble(); + _reassembling = true; + + SchedulerBinding.instance.addPostFrameCallback((_) { + _reassembling = false; + }); + } + // coverage:ignore-end +} + +class _LoadYamlParcel { + final String source; + final Uri? sourceUri; + + const _LoadYamlParcel( + this.source, + this.sourceUri, + ); +} + +Map _loadYaml(_LoadYamlParcel parcel) { + Map cMap = {}; + + late Function(YamlMap, String?) fProcessMap; + + fProcessMap = (yamlMap, keyBase) { + final tKeys = yamlMap.keys; + + for (final String tKey in tKeys.cast()) { + Object? tValue = yamlMap[tKey]; + + if (tValue is YamlMap) { + fProcessMap(tValue, keyBase == null ? tKey : '$keyBase.$tKey'); + } else if (tValue != null) { + cMap['${keyBase != null ? '$keyBase.' : ''}$tKey'] = tValue; + } + } + }; + + final tYaml = loadYaml( + parcel.source, + sourceUrl: parcel.sourceUri, + ); + + if (tYaml is YamlMap) { + fProcessMap( + tYaml, + null, + ); + } else { + throw ArgumentError.value( + parcel.source, + 'source', + 'The source is not a valid YAML map.', + ); + } + + return cMap; +} + +class _PrepareMapParcel { + final Locale locale; + final Map map; + + _PrepareMapParcel(this.locale, this.map); +} + +Map _prepareMap(_PrepareMapParcel parcel) { + final tMap = parcel.map; + final tLocale = parcel.locale.toLanguageTag(); + + for (final i in tMap.entries) { + if (i.value is String) { + // We format it to parse the blocks for faster lookup later. + tMap[i.key] = MessageFormat(i.value as String, locale: tLocale) + ..format({}); + } + } + + return tMap; +} + +/// Manages resources required for localization. +/// +/// For each [Locale], place the yaml file in the directory specified by +/// [I18nEnvironment.l10nPaths]. +/// The file name becomes the identifier returned by +/// [Locale.toLanguageTag] and is normalized by [Intl.canonicalizedLocale]. +/// +/// This class is automatically created during the load of +/// [L10nDelegate] and loads each resource. +class L10n { + static Future> _loadMapFromAssets( + Locale locale, + AssetBundle assetBundle, + String fileName, + List paths, + ) => + Future.wait([ + for (var i in paths) + assetBundle + .loadString( + '$i/$fileName.yaml', + cache: false, + ) + .catchError((e, stackTrace) { + if (!kIsTest) { + // coverage:ignore-start + _logger.warning( + 'Failed to load l10n file: $i/$fileName.yaml\nDid you configure I18nEnvironment correctly?', + L10nLoadAssetsException(original: e), + stackTrace, + ); + // coverage:ignore-end + } + + return 'patapata_dummy_never: dummy'; + }) + .then>( + (v) => platformCompute( + _loadYaml, + _LoadYamlParcel( + v, + Uri.tryParse('$i/$fileName.yaml'), + ), + debugLabel: 'patapata_core:i18n:loadYaml', + ), + ) + .catchError((e, stackTrace) { + _logger.warning( + 'Failed to parse l10n file: $i/$fileName.yaml', + L10nLoadAssetsException(original: e), + stackTrace, + ); + + return {}; + }), + ]) + .then>( + (maps) => maps.reduce((v, e) => v..addAll(e))) + .then((v) => + platformCompute(_prepareMap, _PrepareMapParcel(locale, v))); + + /// Load the yaml file corresponding to [Locale] from the asset. + /// + /// If [assetBundle] is not passed, it will be loaded from [rootBundle]. + static Future fromAssets({ + required Locale locale, + required List paths, + AssetBundle? assetBundle, + }) async { + return L10n( + locale, + await _loadMapFromAssets( + locale, + assetBundle ??= rootBundle, + Intl.canonicalizedLocale(locale.toLanguageTag()), + paths, + ), + ); + } + + final Locale _locale; + + /// The [Locale] supported by this [L10n]. + Locale get locale => _locale; + + final Map _map; + + const L10n(this._locale, this._map); + + /// Returns a string specified by [key] from the loaded yaml file. + /// + /// By passing parameters to [namedParameters], + /// formatting compliant with [MessageFormat] is possible. + /// + /// This method is called from the [l] method. + String lookup( + String key, { + Map? namedParameters, + }) { + final tValue = _map[key]; + + if (tValue == null) { + return key; + } + + if (tValue is MessageFormat) { + return tValue.format(namedParameters ?? const {}); + } else { + return tValue.toString(); + } + } + + /// Returns true if [key] is defined in the yaml file. + bool containsMessageKey(String key) => _map.containsKey(key); + + /// Returns true if [key] is defined in the yaml file. + static bool containsKey({ + required BuildContext context, + required String key, + }) => + Localizations.of(context, L10n)?.containsMessageKey(key) ?? false; +} + +/// Factory to load resources for [L10n]. +/// Called by the [Localizations] Widget. +class L10nDelegate extends LocalizationsDelegate { + L10nDelegate(this._i18n); + + final I18n _i18n; + + L10n? _l10n; + + /// The loaded [L10n] class. + /// If it hasn't been loaded yet, null. + L10n? get l10n => _l10n; + + @override + bool isSupported(Locale locale) { + return _i18n.supportedL10ns.contains(locale); + } + + @override + Future load(Locale locale) => L10n.fromAssets( + locale: locale, + paths: _i18n._l10nPaths, + assetBundle: mockL10nAssetBundle, + ).then((value) => _l10n = value); + + // coverage:ignore-start + @override + bool shouldReload(LocalizationsDelegate old) => _reassembling; + // coverage:ignore-end +} + +@visibleForTesting +AssetBundle? mockL10nAssetBundle; + +/// Thrown if the loading of an asset in [L10n] fails. +class L10nLoadAssetsException extends PatapataCoreException { + const L10nLoadAssetsException({ + super.original, + }) : super(code: PatapataCoreExceptionCode.PPE401); +} diff --git a/packages/patapata_core/lib/src/local_config.dart b/packages/patapata_core/lib/src/local_config.dart new file mode 100644 index 0000000..c6569fa --- /dev/null +++ b/packages/patapata_core/lib/src/local_config.dart @@ -0,0 +1,339 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; + +import 'config.dart'; + +/// Abstract class for the [LocalConfig]. +/// +/// [Config] for saving data locally. +/// Also, please check the mixin for implementing basic functionality in [MemoryLocalConfig]. +abstract class LocalConfig extends Config with ReadableConfig, WritableConfig {} + +/// Class for managing all [LocalConfig] used by the app. +/// If multiple local configurations are added, the most recently added local configuration is used as the current [LocalConfig]. +/// +/// Access to [LocalConfig] is usually done through this class. +/// +/// example: +/// ```dart +/// tApp.getPlugin()?.getBool('key'); +/// ``` +class ProxyLocalConfig extends LocalConfig { + final List _localConfigs = [ + // Make sure _something_ exists. + MockLocalConfig({}), + ]; + final Map _defaults = {}; + + /// Create a [ProxyLocalConfig] + ProxyLocalConfig(); + + /// This adds the [localConfig] to the [LocalConfig]s managed by this class. + Future addLocalConfig(LocalConfig localConfig) async { + await localConfig.init(); + await localConfig.setDefaults(Map.of(_defaults)); + localConfig.addListener(onChange); + _localConfigs.add(localConfig); + + notifyListeners(); + } + + /// This removes [localConfig] from the [LocalConfig]s managed by this class. + void removeLocalConfig(LocalConfig localConfig) { + if (_localConfigs.remove(localConfig)) { + localConfig.dispose(); + notifyListeners(); + } + } + + @override + void dispose() { + super.dispose(); + for (var v in _localConfigs) { + v.dispose(); + } + _localConfigs.clear(); + } + + LocalConfig get _current => _localConfigs.last; + + /// This class checks whether the current [LocalConfig] managed by it contains a key with the name [key]. + @override + bool hasKey(String key) => _current.hasKey(key); + + /// Retrieves the bool value with the name [key] from the current [LocalConfig]. + /// If it cannot be retrieved, [defaultValue] is returned. + @override + bool getBool(String key, {bool defaultValue = Config.defaultValueForBool}) { + if (_current.hasKey(key)) { + return _current.getBool(key, defaultValue: defaultValue); + } + + if (_defaults.containsKey(key) && _defaults[key] is bool) { + return _defaults[key] as bool; + } + + return defaultValue; + } + + /// Retrieves the double value with the name [key] from the current [LocalConfig]. + /// If it cannot be retrieved, [defaultValue] is returned. + @override + double getDouble(String key, + {double defaultValue = Config.defaultValueForDouble}) { + if (_current.hasKey(key)) { + return _current.getDouble(key, defaultValue: defaultValue); + } + + if (_defaults.containsKey(key) && _defaults[key] is double) { + return _defaults[key] as double; + } + + return defaultValue; + } + + /// Retrieves the int value with the name [key] from the current [LocalConfig]. + /// If it cannot be retrieved, [defaultValue] is returned. + @override + int getInt(String key, {int defaultValue = Config.defaultValueForInt}) { + if (_current.hasKey(key)) { + return _current.getInt(key, defaultValue: defaultValue); + } + + if (_defaults.containsKey(key) && _defaults[key] is int) { + return _defaults[key] as int; + } + + return defaultValue; + } + + /// Retrieves the String value with the name [key] from the current [LocalConfig]. + /// If it cannot be retrieved, [defaultValue] is returned. + @override + String getString(String key, + {String defaultValue = Config.defaultValueForString}) { + if (_current.hasKey(key)) { + return _current.getString(key, defaultValue: defaultValue); + } + + if (_defaults.containsKey(key) && _defaults[key] is String) { + return _defaults[key] as String; + } + + return defaultValue; + } + + /// This sets the default values for all [LocalConfig] managed by this class to the values specified in [defaults]. + /// If values are already set, it overwrites them with the default values. + @override + Future setDefaults(Map defaults) async { + _defaults + ..clear() + ..addAll(defaults); + + // After we add, we hint to the actual + // LocalConfig's about the defaults as well. + // They are expected to only return true for hasKey + // when not using default values. + for (var tConfig in _localConfigs) { + await tConfig.setDefaults(defaults); + } + } + + /// This removes the value with the name [key] from the current [LocalConfig]. + @override + Future reset(String key) async { + await _current.reset(key); + } + + /// This removes all values from the current [LocalConfig]. + @override + Future resetAll() async { + await _current.resetAll(); + } + + /// This removes the values with the names specified in [keys] from the current [LocalConfig]. + @override + Future resetMany(List keys) async { + await _current.resetMany(keys); + } + + /// This sets the bool value [value] with the name [key] to the current [LocalConfig]. + @override + Future setBool(String key, bool value) async { + await _current.setBool(key, value); + } + + /// This sets the double value [value] with the name [key] to the current [LocalConfig]. + @override + Future setDouble(String key, double value) async { + await _current.setDouble(key, value); + } + + /// This sets the int value [value] with the name [key] to the current [LocalConfig]. + @override + Future setInt(String key, int value) async { + await _current.setInt(key, value); + } + + /// This sets the String value [value] with the name [key] to the current [LocalConfig]. + @override + Future setString(String key, String value) async { + await _current.setString(key, value); + } + + /// This sets the key-value pairs specified in the [objects] map of type `Map` to the current [LocalConfig]. + @override + Future setMany(Map objects) async { + await _current.setMany(objects); + } +} + +/// A mixin that implements default functionality in [LocalConfig]. +/// If you don't need to implement special features, you can use this mixin to create a `LocalConfig` +/// +/// example: +/// ```dart +/// class HogehogeLocalConfig extends LocalConfig with MemoryLocalConfig { +/// // Processes you want to add, override, etc... +/// @override +/// bool getBool(String key, {bool defaultValue = Config.defaultValueForBool}) { +/// return super.getBool(key, defaultValue: false); +/// } +/// ... +/// } +/// ``` +mixin MemoryLocalConfig on LocalConfig { + /// The data managed by this class, in the form of a Map with keys and values. + @protected + final Map store = {}; + + /// The default data for the data managed by this class, in the form of a Map with keys and values. + @protected + final Map defaults = {}; + + @override + bool getBool(String key, {bool defaultValue = Config.defaultValueForBool}) { + return store.containsKey(key) && store[key] is bool + ? store[key] as bool + : defaults.containsKey(key) && defaults[key] is bool + ? defaults[key] as bool + : defaultValue; + } + + @override + double getDouble(String key, + {double defaultValue = Config.defaultValueForDouble}) { + return store.containsKey(key) && store[key] is double + ? store[key] as double + : defaults.containsKey(key) && defaults[key] is double + ? defaults[key] as double + : defaultValue; + } + + @override + int getInt(String key, {int defaultValue = Config.defaultValueForInt}) { + return store.containsKey(key) && store[key] is int + ? store[key] as int + : defaults.containsKey(key) && defaults[key] is int + ? defaults[key] as int + : defaultValue; + } + + @override + String getString(String key, + {String defaultValue = Config.defaultValueForString}) { + return store.containsKey(key) && store[key] is String + ? store[key] as String + : defaults.containsKey(key) && defaults[key] is String + ? defaults[key] as String + : defaultValue; + } + + @override + bool hasKey(String key) { + return store.containsKey(key); + } + + @override + Future setDefaults(Map defaults) async { + this.defaults + ..clear() + ..addAll(defaults); + } + + @override + Future reset(String key) async { + store.remove(key); + notifyListeners(); + } + + @override + Future resetAll() async { + store.clear(); + notifyListeners(); + } + + @override + Future resetMany(List keys) async { + bool tChanged = false; + + for (final k in keys) { + if (store.remove(k) != null) { + tChanged = true; + } + } + + if (tChanged) { + notifyListeners(); + } + } + + @override + Future setBool(String key, bool value) async { + store[key] = value; + notifyListeners(); + } + + @override + Future setDouble(String key, double value) async { + store[key] = value; + notifyListeners(); + } + + @override + Future setInt(String key, int value) async { + store[key] = value; + notifyListeners(); + } + + @override + Future setString(String key, String value) async { + store[key] = value; + notifyListeners(); + } + + @override + Future setMany(Map objects) async { + for (final i in objects.entries) { + store[i.key] = i.value; + } + + notifyListeners(); + } +} + +/// A [LocalConfig] class for mock purposes, used in tests and similar scenarios. +/// It is not intended for use by the application. +/// @nodoc +class MockLocalConfig extends LocalConfig with MemoryLocalConfig { + /// Create a [MockLocalConfig] + /// @nodoc + MockLocalConfig(Map store) { + this.store.addAll(store); + } +} diff --git a/packages/patapata_core/lib/src/log.dart b/packages/patapata_core/lib/src/log.dart new file mode 100644 index 0000000..a988f83 --- /dev/null +++ b/packages/patapata_core/lib/src/log.dart @@ -0,0 +1,988 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:stack_trace/stack_trace.dart'; + +import 'dart:ffi' if (dart.library.html) 'fake_ffi.dart'; + +import 'app.dart'; +import 'util.dart'; +import 'error.dart'; + +typedef ReportRecordFilter = ReportRecord? Function(ReportRecord record); + +const int _kLineLimit = 1023; + +void _debugLogMuter(String? message, {int? wrapWidth}) {} + +/// Settings for [Log] system +mixin LogEnvironment { + /// The log level ([Level.value]) that patapata processes. + int get logLevel; + + /// If true, outputs the contents of the log to the debug console. + bool get printLog => kDebugMode; +} + +/// Provides an implementation for [Logger.onRecord]. +/// +/// When a message is added to [Logger], [report] is automatically called and +/// [ReportRecord] filtered by [filter] is added to [reports]. +/// +/// Filters can be added with [addFilter] or [ignoreType]. +/// If a filter returns null, that record is ignored. +/// +/// You can add a filter that changes the log level of records that match regular +/// expressions in the remote config (`patapataLogLevelFilters`). +/// +/// Example: Firebase Remote Config +/// ```json +/// { +/// 'foo': 800, +/// 'bar': 1000 +/// } +/// ``` +/// +/// Each filter is processed from the top. +/// +/// The log level to be processed can be set in [LogEnvironment.logLevel] or +/// in the remote config (`patapata_log_level`). +/// (default is [Level.INFO]) +/// +/// If settings are made in both [LogEnvironment.logLevel] and the remote config, +/// the remote config setting takes precedence. +/// +/// This class is automatically initialized during the initialization of +/// patapata [App] and can be accessed from [App.log]. +class Log { + static const String kReportMechanism = 'patapataReport'; + static const String kRootLogMechanism = 'patapataRootLog'; + static const String kUnhandledErrorMechanism = 'patapataUnhandledError'; + static const String kFlutterErrorMechanism = 'patapataFlutterError'; + static const String kNativeMechanism = 'patapataNativeThrowable'; + + static const String kRemoteConfigLevelFilters = 'patapataLogLevelFilters'; + + final App app; + StreamSubscription? _rootLogPrintSubscription; + DebugPrintCallback? _originalDebugPrint; + StreamSubscription? _rootLogDelegateSubscription; + FlutterExceptionHandler? _originalOnFlutterError; + final _filters = []; + final _typeFilters = {}; + + final _duplicateTracker = Expando('patapata.Log'); + + final _reportStreamController = StreamController.broadcast(); + + /// Returns the stream of [ReportRecord] processed by [report]. + Stream get reports => _reportStreamController.stream; + + Log(this.app) { + final tEnvironment = app.environment; + logPrinting = switch (tEnvironment) { + LogEnvironment() => tEnvironment.printLog, + // debugPrint's implementation is actually quite heavy even in release mode. + // So we disable it in release mode. + _ => kDebugMode + }; + setLevelByValue(-kPataInHex); + + _rootLogDelegateSubscription = + Logger.root.onRecord.listen(_onRootLogRecordDelegate); + + _originalOnFlutterError = FlutterError.onError; + FlutterError.onError = _onFlutterError; + + App.appStageChangeStream + .firstWhere((e) => + e == app && e.stage == AppStage.initializingPluginsWithRemoteConfig) + .then((_) { + // When RemoteConfig is ready in our app, start listening + // for RemoteConfig changes and grab and parse our configuration. + _onRemoteConfigChange(); + app.remoteConfig.addListener(_onRemoteConfigChange); + }); + } + + Map? _levelFilters; + String? _remoteConfigLevelFiltersString; + + void _onRemoteConfigChange() { + final tLevelFiltersString = + app.remoteConfig.getString(Log.kRemoteConfigLevelFilters); + + if (_remoteConfigLevelFiltersString == tLevelFiltersString) { + return; + } + + _remoteConfigLevelFiltersString = tLevelFiltersString; + + if (tLevelFiltersString.isEmpty) { + _levelFilters = null; + } else { + try { + final tDecodedLevelFilters = + (jsonDecode(tLevelFiltersString) as Map).cast(); + _levelFilters = { + for (var e in tDecodedLevelFilters.entries) + RegExp(e.key): e.value == null + ? null + : Level.LEVELS.firstWhere((v) => v.value == e.value!, + orElse: () => Level('REMOTE', e.value!)), + }; + } catch (e) { + // Print directly, if this was a bad error that happens + // many many times, the report system won't have a way to + // filter out this message and cause account limit over issues + // all over the place. + _print( + 'Log: RemoteConfig ${Log.kRemoteConfigLevelFilters} parsing failed: $e'); + } + } + } + + /// Releases each resource. + void dispose() { + app.remoteConfig.removeListener(_onRemoteConfigChange); + + _rootLogPrintSubscription?.cancel(); + _rootLogPrintSubscription = null; + + if (_originalDebugPrint != null) { + debugPrint = _originalDebugPrint!; + _originalDebugPrint = null; + } + + _rootLogDelegateSubscription?.cancel(); + _rootLogDelegateSubscription = null; + + if (_originalOnFlutterError != null) { + FlutterError.onError = _originalOnFlutterError; + _originalOnFlutterError = null; + } + + _reportStreamController.close(); + } + + Level _level = Level.INFO; + + set level(Level value) { + _level = value; + Logger.root.level = _level; + } + + /// The level of the log to be processed. + Level get level => _level; + + /// Specifies the level of the log to be processed using [Level.value]. + /// + /// If `-[kPataInHex]` is specified, + /// [LogEnvironment.logLevel] is used. (If not set, it defaults to [Level.INFO]) + void setLevelByValue(int value) { + final tDefaultLogLevel = app.environment is LogEnvironment + ? (app.environment as LogEnvironment).logLevel + : Level.INFO.value; + + if (value == -kPataInHex) { + value = tDefaultLogLevel; + } + + level = Level.LEVELS.firstWhere((v) => v.value == value, + orElse: () => Level('REMOTE', value)); + } + + bool _seenBefore(Object error) { + // From Expando's documentation... + // The object must not be a number, a string, a boolean, `null`, a + // `dart:ffi` pointer, a `dart:ffi` struct, or a `dart:ffi` union. + if (error is num || error is String || error is bool) { + return false; + } + + if (!kIsWeb) { + if (error is Pointer || error is Struct || error is Union) { + return false; + } + } + + if (_duplicateTracker[error] == true) { + return true; + } + + _duplicateTracker[error] = true; + return false; + } + + /// If true, the log contents will be output on the debug console and system log. + bool get logPrinting => _rootLogPrintSubscription != null; + set logPrinting(bool value) { + if (value) { + _rootLogPrintSubscription ??= + Logger.root.onRecord.listen(_onRootLogRecordPrint); + + if (debugPrint == _debugLogMuter) { + debugPrint = _originalDebugPrint!; + } + } else { + _rootLogPrintSubscription?.cancel(); + _rootLogPrintSubscription = null; + + if (debugPrint != _debugLogMuter) { + _originalDebugPrint = debugPrint; + debugPrint = _debugLogMuter; + } + } + } + + void _print(String message) { + if (message.isEmpty == true) { + return; + } + + message.split('\n').forEach((message) { + if (utf8.encode(message).length < 1024) { + debugPrint(message); + return; + } + final tLines = _splitStringByUtf8ByteLength(message); + for (var i = 0; i < tLines.length; i++) { + debugPrint(tLines[i]); + } + }); + } + + List _splitStringByUtf8ByteLength(String message) { + return [ + ...(() sync* { + final tRunes = message.runes.toList(growable: false); + final tRunesLength = tRunes.length; + int tByteLength = 0; + int tStart = 0; + + for (var i = 0; i < tRunesLength; i++) { + final tRune = tRunes[i]; + + if (tRune <= 0xFF) { + if (tByteLength + 1 > _kLineLimit) { + yield message.substring(tStart, i); + tStart = i; + tByteLength = 0; + } + + tByteLength++; + } else { + final tRuneBytesLength = + utf8.encode(String.fromCharCode(tRune)).length; + + if (tByteLength + tRuneBytesLength > _kLineLimit) { + yield message.substring(tStart, i); + tStart = i; + tByteLength = 0; + } + + tByteLength += tRuneBytesLength; + } + } + + if (tStart < tRunesLength) { + yield message.substring(tStart, tRunesLength); + } + })(), + ]; + } + + void _onRootLogRecordPrint(LogRecord record) { + _print(record.toString()); + + final tError = record.error; + + if (tError is FlutterErrorDetails) { + // Flutter errors print stack traces and everything + // needed in the toString() method. So we are already done. + return; + } + + final tErrorString = tError?.toString(); + + if (tErrorString != null && record.message != tErrorString) { + _print(tErrorString); + } + + if (record.stackTrace != null) { + _print(record.stackTrace.toString()); + } + } + + void _onRootLogRecordDelegate(LogRecord record) { + if (record.object is ReportRecord) { + report(record.object as ReportRecord); + } else { + report(ReportRecord.fromLogRecord(record)); + } + } + + void _onFlutterError(FlutterErrorDetails details) { + report( + ReportRecord( + level: Level.SEVERE, + object: details, + error: details.exception, + stackTrace: details.stack, + mechanism: Log.kFlutterErrorMechanism, + ), + ); + + if (_originalOnFlutterError != null) { + _originalOnFlutterError!(details); + } + } + + /// Filters [record] and adds the result to [reports]. + /// If the filter returns null, that record is ignored. + /// + /// This method is automatically called when a message is added to [Logger]. + void report(ReportRecord record) { + if (record.level < level) { + return; + } + + if (record.object != null && _seenBefore(record.object!)) { + return; + } + + if (record.error != null && _seenBefore(record.error!)) { + return; + } + + final tRecord = filter(record); + + if (tRecord == null) { + return; + } + + final tError = tRecord.error; + + if (tError is PatapataException) { + scheduleMicrotask(() { + try { + tError.onReported(tRecord); + } catch (e) { + _print(e.toString()); + } + }); + } + + _reportStreamController.add(tRecord); + } + + ReportRecord? _shouldIgnoreByType(ReportRecord record) => + record.error != null && _typeFilters.contains(record.error.runtimeType) + ? null + : record; + + /// Adds a filter. + /// + /// If the filter returns null, that record is ignored. + void addFilter(ReportRecordFilter test) { + _filters.add(test); + } + + /// Removes the filter added with [addFilter]. + void removeFilter(ReportRecordFilter test) { + _filters.remove(test); + } + + /// Adds a filter to ignore if [ReportRecord.error] matches [type]. + void ignoreType(Type type) { + _typeFilters.add(type); + + if (_typeFilters.length == 1) { + _filters.add(_shouldIgnoreByType); + } + } + + /// Removes the filter added with [ignoreType]. + void unignoreType(Type type) { + _typeFilters.remove(type); + + if (_typeFilters.isEmpty) { + _filters.remove(_shouldIgnoreByType); + } + } + + /// Filters [object] and returns the result. + ReportRecord? filter(ReportRecord object) { + ReportRecord? tObject = object; + + // First check local filters + for (var tFilter in _filters) { + tObject = tFilter(tObject!); + + if (tObject == null) { + return null; + } + } + + // Then check remote filters + tObject = _filterFromRemote(tObject!); + + return tObject; + } + + ReportRecord? _filterFromRemote(ReportRecord record) { + if (_levelFilters == null) { + return record; + } + + for (var i in _levelFilters!.entries) { + if (record.level == i.value) { + continue; + } + + if (record.message.isNotEmpty && i.key.hasMatch(record.message)) { + if (i.value == null) { + return null; + } + + record = record.copyWith(level: i.value); + } + + if (record.error != null) { + final tToCheck = record.error!.toString(); + + if (tToCheck.isNotEmpty && i.key.hasMatch(tToCheck)) { + if (i.value == null) { + return null; + } + + record = record.copyWith(level: i.value); + } + } + + if (record.object != null) { + final tToCheck = record.object!.toString(); + + if (tToCheck.isNotEmpty && i.key.hasMatch(tToCheck)) { + if (i.value == null) { + return null; + } + + record = record.copyWith(level: i.value); + } + } + } + + return record; + } +} + +/// A log entry representation used to propagate information from [Logger] to +/// individual handlers. +/// +/// This class is used by the [Log] system. +/// You can also use this class to send your own reports. +class ReportRecord implements LogRecord { + @override + final Level level; + @override + final String message; + + /// Non-string message passed to Logger. + @override + final Object? object; + + /// Logger where this record is stored. + @override + final String loggerName; + + /// Time when this record was created. + @override + final DateTime time; + + /// Unique sequence number greater than all log records created before it. + @override + final int sequenceNumber; + + /// Associated error (if any) when recording errors messages. + @override + final Object? error; + + /// Associated stackTrace (if any) when recording errors messages. + @override + final StackTrace? stackTrace; + + /// Zone of the calling code which resulted in this LogRecord. + @override + final Zone? zone; + + /// In what way this record was created/collected. + final String mechanism; + + /// Any extra details to send with the report. + final Map? extra; + + /// Used to deduplicate events by grouping ones with the same fingerprint together. + final List? fingerprint; + + /// Creates a new instance of [ReportRecord]. + /// + /// [message] or [object] or [error] must be specified. + /// If [stackTrace] is not specified, it is automatically set to the stack + /// trace of [error]. + /// If [time] is not specified, it is automatically set to the current time. + /// If [sequenceNumber] is not specified, it is automatically set to the + /// sequence number of the [LogRecord] with [Level.INFO] and empty [message]. + /// If [mechanism] is not specified, it is automatically set to + /// [Log.kReportMechanism]. + ReportRecord({ + required this.level, + String? message, + this.object, + this.loggerName = 'report', + DateTime? time, + int? sequenceNumber, + this.error, + StackTrace? stackTrace, + this.zone, + this.mechanism = Log.kReportMechanism, + this.extra, + this.fingerprint, + }) : assert(message != null || object != null || error != null), + time = time ?? DateTime.now(), + sequenceNumber = + sequenceNumber ?? LogRecord(Level.INFO, '', '').sequenceNumber, + message = + message ?? object?.toString() ?? error?.toString() ?? '', + stackTrace = (stackTrace != null && stackTrace != StackTrace.empty + ? stackTrace + : null) ?? + (error is Error && + error.stackTrace != null && + error.stackTrace != StackTrace.empty + ? error.stackTrace + : null); + + /// Creates a new instance of [ReportRecord] from [LogRecord]. + factory ReportRecord.fromLogRecord(LogRecord record) => ReportRecord( + level: record.level, + message: record.message, + object: record.object, + loggerName: record.loggerName, + time: record.time, + sequenceNumber: record.sequenceNumber, + error: record.error, + fingerprint: (record.error is PatapataException) + ? (record.error as PatapataException).fingerprint + : null, + stackTrace: record.stackTrace, + zone: record.zone, + mechanism: Log.kRootLogMechanism, + ); + + @override + String toString() => '[${level.name}] $loggerName: $message'; + + /// Returns a new instance of [ReportRecord] with the specified attributes + /// replaced. + ReportRecord copyWith({ + Level? level, + String? message, + Object? object, + String? loggerName, + DateTime? time, + int? sequenceNumber, + Object? error, + StackTrace? stackTrace, + Zone? zone, + String? mechanism, + Map? extra, + List? fingerprint, + }) { + return ReportRecord( + level: level ?? this.level, + message: message ?? this.message, + object: object ?? this.object, + loggerName: loggerName ?? this.loggerName, + time: time ?? this.time, + sequenceNumber: sequenceNumber ?? this.sequenceNumber, + error: error ?? this.error, + stackTrace: stackTrace ?? this.stackTrace, + zone: zone ?? this.zone, + mechanism: mechanism ?? this.mechanism, + extra: extra ?? this.extra, + fingerprint: fingerprint ?? this.fingerprint, + ); + } +} + +final Map _nativeLoggingMethodChannels = {}; + +final _nativeLogger = Logger('patapata.native'); + +final _nativeLoggerLevelMap = { + 0: Level('EMERGENCY', Level.SHOUT.value), + 1: Level('ALERT', Level.SHOUT.value - 10), + 2: Level('CRITICAL', Level.SHOUT.value - 20), + 3: Level('ERROR', Level.SEVERE.value), + 4: Level.WARNING, + 5: Level('NOTICE', Level.INFO.value + 10), + 6: Level.INFO, + 7: Level('DEBUG', Level.FINE.value), +}; + +Future _nativeLoggingMethodHandler(MethodCall call) async { + if (call.method != 'logging') { + return null; + } + + if (call.arguments is! Map) { + return null; + } + + final tLoggingPackage = (call.arguments as Map).cast(); + + Level tLevel = Level.INFO; + + if (tLoggingPackage['level'] is int && + _nativeLoggerLevelMap.containsKey(tLoggingPackage['level'] as int)) { + tLevel = _nativeLoggerLevelMap[tLoggingPackage['level'] as int]!; + } + + String? tMessage; + + if (tLoggingPackage['message'] is String) { + tMessage = tLoggingPackage['message'] as String?; + } + + DateTime tTimestamp; + + if (tLoggingPackage['timestamp'] is int) { + tTimestamp = DateTime.fromMillisecondsSinceEpoch( + tLoggingPackage['timestamp'] as int, + isUtc: true, + ); + } else { + tTimestamp = DateTime.now(); + } + + Map? tExtra; + + if (tLoggingPackage['metadata'] is String) { + final tMetadata = tLoggingPackage['metadata'] as String?; + + if (tMetadata != null) { + try { + tExtra = (jsonDecode(tMetadata) as Map).cast(); + } catch (e) { + // ignore + } + } + } + + NativeThrowable? tThrowable; + + if (tLoggingPackage['throwable'] is Map) { + tThrowable = NativeThrowable.fromMap( + (tLoggingPackage['throwable'] as Map).cast()); + } + + _nativeLogger.log( + tLevel, + ReportRecord( + level: tLevel, + message: tMessage, + error: tThrowable, + stackTrace: tThrowable?.chain, + time: tTimestamp, + extra: tExtra, + mechanism: Log.kNativeMechanism, + ), + ); + + return null; +} + +/// +/// This is a regular expression to parse and separate a single frame (line) of a +/// Java StackTrace. +/// +/// `(.+?[\.].+?(?=\.))?` Capture if there is a Java package name at the beginning. +/// `([^\.]+?\.[^\.(]+?)` Capture the Java class name and method. +/// `(?:\((.+?)(?::([\d]+?))?\))?` Capture the filename if present, followed by the line number if available. +/// +final RegExp _nativeAndroidFrameParser = RegExp( + r'^(.+?[\.].+?(?=\.))?\.?([^\.]+?\.[^\.(]+?)(?:\((.+?)(?::([\d]+?))?\))?$'); + +/// +/// This is a regular expression to parse and separate a single frame (line) of an +/// iOS StackTrace. +/// +/// `.+?\s+` Drop the leading index. +/// `(.+?)` Capture the iOS Framework part. +/// `\s+.+?\s+` Ignore the memory address. +/// `(.+?)` Capture the function name. +/// `(?:\s+\+\s+(\d+)?)?` If present, capture the line number. +/// +final RegExp _nativeIosFrameParser = + RegExp(r'^.+?\s+(.+?)\s+.+?\s+(.+?)(?:\s+\+\s+(\d+)?)?$'); + +/// Class for exceptions that occurred natively. +/// +/// By registering the MethodChannel with [registerNativeThrowableMethodChannel], +/// you can notify the patapata's [Log] system of exceptions that occurred natively. +/// +/// Currently, this feature only supports Android and iOS. +/// +/// example: Android (Kotlin) +/// +/// dart +/// ```dart +/// NativeThrowable.registerNativeThrowableMethodChannel('patapata/logging') +/// ``` +/// Android +/// ```kotlin +/// val methodChannel = MethodChannel(binding.binaryMessenger, "patapata/logging") +/// +/// methodChannel.invokeMethod( +/// "logging", +/// mapOf( +/// "level" to 3, +/// +/// "message" to "Log message", +/// +/// "metadata" to "{\"foo\": \"bar\"}", +/// +/// "timestamp" to System.currentTimeMillis(), +/// +/// // throwable is Throwable class. +/// // For Android, instead of the below mapOf, toPatapataMap from a Throwable type can be used. +/// "throwable" to mapOf( +/// "type" to throwable.javaClass.name, +/// "message" to throwable.message, +/// "stackTrace" to throwable.stackTrace.map { it.toString() }, +/// "cause" to throwable.cause?.run { throwableToMap(this) } +/// ) +/// ) +/// ) +/// ``` +class NativeThrowable { + /// For Android, it's the javaClassName; for iOS, it's things like the error domain. + final String? type; + + /// Error details. + final String? message; + + /// A chain of stack traces. + /// + /// This property is represented as `stackTrace` in the Map. + final Chain? chain; + + /// Cause of the exception. + final NativeThrowable? cause; + + const NativeThrowable({ + this.type, + this.message, + this.chain, + this.cause, + }); + + /// Registers the MethodChannel with the [name]. + /// + /// In Native, by creating a MethodChannel with the [name] and invoking the + /// `logging` method, you can notify the app's [Log] system. + /// + /// arguments: Type is Map + /// ```json + /// { + /// // Log level + /// // Conforms to syslog severity level. + /// // + /// // This value corresponds to the value of the Level class defined on the dart side. + /// // 0: Level('EMERGENCY', Level.SHOUT.value), + /// // 1: Level('ALERT', Level.SHOUT.value - 10), + /// // 2: Level('CRITICAL', Level.SHOUT.value - 20), + /// // 3: Level('ERROR', Level.SEVERE.value), + /// // 4: Level.WARNING, + /// // 5: Level('NOTICE', Level.INFO.value + 10), + /// // 6: Level.INFO, + /// // 7: Level('DEBUG', Level.FINE.value), + /// // + /// // If omitted, it is treated as 6. + /// "level": 3, + /// + /// + /// // required: Log message + /// "message": "Log message", + /// + /// // json string of extra data + /// // This value is passed to [ReportRecord.extra] on the dart side. + /// "metadata": "{\"foo\": \"bar\"}", + /// + /// // timestamp(Unix time) + /// "timestamp": 1234567890, + /// + /// // Map of Native exception classes converted to this [NativeThrowable] class. + /// // For Android, by calling the `toPatapataMap` that extends the Java's `Throwable` class, + /// // you can convert it to the map of this class. + /// "throwable": Throwable.toPatapataMap() + /// } + /// ``` + /// + static void registerNativeThrowableMethodChannel(String name) { + if (!_nativeLoggingMethodChannels.containsKey(name)) { + _nativeLoggingMethodChannels[name] = MethodChannel(name) + ..setMethodCallHandler(_nativeLoggingMethodHandler); + } + } + + /// Unregisters the MethodChannel. + static void unregisterNativeThrowableMethodChannel(String name) { + if (_nativeLoggingMethodChannels.containsKey(name)) { + _nativeLoggingMethodChannels[name]?.setMethodCallHandler(null); + _nativeLoggingMethodChannels.remove(name); + } + } + + /// Creates a new instance of [NativeThrowable] from the Map. + factory NativeThrowable.fromMap(Map map) { + final tStackTrace = (map['stackTrace'] as List?)?.cast(); + final tCause = map['cause'] is Map + ? NativeThrowable.fromMap((map['cause'] as Map).cast()) + : null; + + late RegExp tMatcher; + late int tPackageGroup; + int? tFileGroup; + late int tMethodGroup; + late int tLineGroup; + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + tMatcher = _nativeAndroidFrameParser; + tPackageGroup = 1; + tFileGroup = 3; + tMethodGroup = 2; + tLineGroup = 4; + break; + case TargetPlatform.iOS: + tMatcher = _nativeIosFrameParser; + tPackageGroup = 1; + tFileGroup = null; + tMethodGroup = 2; + tLineGroup = 3; + break; + default: + // ignore: avoid_print + print('$defaultTargetPlatform not supported for NativeThrowable yet.'); + break; + } + + final tChain = Chain([ + if (tStackTrace != null) + Trace( + tStackTrace.map( + (v) { + try { + final tMatch = tMatcher.firstMatch(v); + + if (tMatch != null) { + final tLine = tMatch.group(tLineGroup); + return Frame( + Uri.file( + '${tMatch.group(tPackageGroup) ?? ''}${(tFileGroup != null ? '/${tMatch.group(tFileGroup)}' : null) ?? '/'}' + .replaceAll(' ', ''), + windows: false), + tLine != null ? int.tryParse(tLine) : null, + null, + tMatch.group(tMethodGroup), + ); + } else { + return Frame.parseFriendly(v); + } + } catch (e) { + return Frame.parseFriendly(v); + } + }, + ), + original: tStackTrace.join('\n'), + ), + ...((NativeThrowable? cause) sync* { + while (cause != null) { + final tTraces = cause.chain?.traces; + + if (tTraces != null) { + for (var tTrace in tTraces) { + yield tTrace; + } + } + + cause = cause.cause; + } + })(tCause), + ]); + + return NativeThrowable( + type: map['type'] as String?, + message: map['message'] as String?, + chain: tChain.traces.isNotEmpty ? tChain : null, + cause: tCause, + ); + } + + /// Converts this class to a Map. + Map toMap() { + final tCause = cause?.toMap(); + final tFrames = + chain?.traces.isNotEmpty == true ? chain?.traces.first.frames : null; + + final tPratformStackTrace = List.generate(tFrames?.length ?? 0, (index) { + final tFrame = tFrames![index]; + try { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final tLibrary = tFrame.library.split('/'); + final tLine = tFrame.line != null ? ':${tFrame.line}' : ''; + final tPackageGroup = tLibrary[0].replaceAll('', ''); + final tFileGroup = tLibrary[1].replaceAll('', ''); + final tMethodGroup = tFrame.member ?? ''; + + return ('$tPackageGroup${tPackageGroup.isNotEmpty ? '.' : ''}$tMethodGroup($tFileGroup$tLine)') + .replaceAll('', ' '); + case TargetPlatform.iOS: + final tLibrary = tFrame.library.split('/'); + final tLine = tFrame.line != null ? ' + ${tFrame.line}' : ''; + final tPackageGroup = tLibrary[0].replaceAll('', ''); + final tMethodGroup = tFrame.member ?? ''; + + // Dummy since memory address information is lost. + return ('${index + 1} $tPackageGroup 0x0000000000000000 $tMethodGroup$tLine') + .replaceAll('', ' '); + default: + // ignore: avoid_print + print( + '$defaultTargetPlatform not supported for NativeThrowable yet.'); + return tFrame.toString(); + } + } catch (e) { + return tFrame.toString(); + } + }); + + return { + 'type': type, + 'message': message, + 'stackTrace': tPratformStackTrace.isNotEmpty ? tPratformStackTrace : null, + 'cause': tCause, + }; + } +} diff --git a/packages/patapata_core/lib/src/logic_state.dart b/packages/patapata_core/lib/src/logic_state.dart new file mode 100644 index 0000000..b161f3f --- /dev/null +++ b/packages/patapata_core/lib/src/logic_state.dart @@ -0,0 +1,439 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +import 'exception.dart'; + +/// Provides the functionality of a Finite State Machine. +/// +/// The state machine holds multiple [LogicStateFactory] in a list. +/// At the creation of the machine, a [LogicState] is generated from the leading factory, +/// and the process starts. +/// +/// When a [LogicState] completes, the machine fetches the list of [LogicStateTransition] from the +/// [LogicStateFactory] of that state and finds the factory of the next [LogicState] to +/// execute from the top of the list. +/// If the list is empty, [LogicStateMachine.complete] becomes true, +/// and [LogicStateMachine.current] becomes [LogicStateComplete]. +/// +/// example: +/// ```dart +/// LogicStateMachine( +/// [ +/// LogicStateFactory( +/// () => StateA(this), +/// [ +/// LogicStateTransition(), +/// LogicStateTransition(), +/// ], +/// ), +/// LogicStateFactory( +// () => StateB(this), +/// [ +/// LogicStateTransition(), +/// ], +/// ), +/// LogicStateFactory( +/// () => StateC(this), +/// [], +/// ), +/// ], +/// ); +/// ``` +class LogicStateMachine with ChangeNotifier { + final List _factories; + final List<({Type stateType, bool backAllowed})> _stateHistories = []; + LogicState __currentState; + LogicState get _currentState => __currentState; + set _currentState(LogicState value) { + _stateHistories.add(( + stateType: value.runtimeType, + backAllowed: value.backAllowed, + )); + __currentState = value; + } + + LogicStateFactory _currentFactory; + + /// Creates a new [LogicStateMachine]. + /// Starts processing immediately from the top of [factories] upon creation. + LogicStateMachine(List factories, [Object? initialData]) + : assert(factories.isNotEmpty), + _factories = factories, + _currentFactory = factories.first, + __currentState = factories.first.create() { + _stateHistories.add(( + stateType: __currentState.runtimeType, + backAllowed: __currentState.backAllowed, + )); + _initState(initialData); + } + + /// The current state. + /// + /// If all states complete successfully, [LogicStateComplete] is set. + /// + /// If terminated due to a state error, [LogicStateError] is set. + /// This is the same as what is retrieved by [error]. + LogicState get current => _currentState; + + bool _complete = false; + + /// Returns true if the machine's processing is complete. + bool get complete => _complete; + + /// The error details when the machine terminates due to an error. + LogicStateError? get error => (complete && _currentState is LogicStateError) + ? _currentState as LogicStateError + : null; + + void _initState([Object? initialData]) { + final tState = _currentState; + tState._machine = this; + tState.init(initialData); + assert(tState._initialized); + } + + void _completeFinished() { + _complete = true; + _currentState = LogicStateComplete(); + _initState(); + } + + void _completeError(Object error, [StackTrace? stackTrace]) { + _complete = true; + _currentState = LogicStateError(error, stackTrace); + _initState(); + } + + void _notifyListeners() { + notifyListeners(); + } + + @override + String toString() => + 'LogicStateMachine: complete=$_complete, error=$error, current=$current'; +} + +/// Thrown when LogicStateTransition is not found. +class LogicStateTransitionNotFound extends PatapataCoreException { + const LogicStateTransitionNotFound() + : super(code: PatapataCoreExceptionCode.PPE101); +} + +/// Thrown when [current] is not allowed to transition to [next]. +class LogicStateTransitionNotAllowed extends PatapataCoreException { + final LogicState current; + final LogicState next; + + const LogicStateTransitionNotAllowed( + this.current, + this.next, + ) : super(code: PatapataCoreExceptionCode.PPE102); + + @override + String toString() => + 'LogicStateTransitionNotAllowed: $current is not allowed to transition to $next.'; +} + +/// Thrown when [current] is not allowed to transition to anything. +class LogicStateAllTransitionsNotAllowed extends PatapataCoreException { + final LogicState current; + + const LogicStateAllTransitionsNotAllowed( + this.current, + ) : super(code: PatapataCoreExceptionCode.PPE103); + + @override + String toString() => + 'LogicStateAllTransitionsNotAllowed: $current is not allowed to transition to anything.'; +} + +/// Thrown when [current] is not current in [LogicStateMachine]. +class LogicStateNotCurrent extends PatapataCoreException { + final LogicState current; + + const LogicStateNotCurrent( + this.current, + ) : super(code: PatapataCoreExceptionCode.PPE104); + + @override + String toString() => 'LogicStateNotCurrent: $current is not current.'; +} + +typedef LogicStateCompleter = void Function(); + +/// The state to be executed in [LogicStateMachine]. +abstract class LogicState { + bool _initialized = false; + late final LogicStateMachine _machine; + late final _completer = Completer(); + + /// If true, allows going back via [backByType]. (default is false) + bool backAllowed = false; + + /// Returns a Future that waits until the state is complete. + Future get onComplete => _completer.future; + + /// Completes the state and transitions to the next [LogicState]. + /// + /// The next [LogicState] to transition to is found from the [LogicStateTransition] of the + /// [LogicStateFactory] from which it was created. + /// If the [LogicStateTransition] list is empty, the processing of [LogicStateMachine] + /// will be completed. + /// + /// If [LogicStateTransition] exists but all are in a non-transitionable state + /// (for example, if `predicate` returns false for all), + /// [LogicStateAllTransitionsNotAllowed] will be thrown. + /// + /// [complete] and [completeError] can only be called once. + void complete() { + assert(_initialized); + + if (!this()) { + throw LogicStateNotCurrent(this); + } + + final tTransitions = _machine._currentFactory._transitions; + LogicStateFactory? tNextFactory; + LogicState? tNextState; + + for (var tTransition in tTransitions) { + final tNextFactoryCandidate = _machine._factories.firstWhere( + (v) => v.type == tTransition.nextType, + orElse: () => throw const LogicStateTransitionNotFound(), + ); + final tNextStateCandidate = tNextFactoryCandidate.create(); + + if (tTransition._predicate(this, tNextStateCandidate)) { + tNextFactory = tNextFactoryCandidate; + tNextState = tNextStateCandidate; + + break; + } + } + + if (tTransitions.isEmpty) { + _complete(); + _machine._completeFinished(); + } else if (tNextFactory == null || tNextState == null) { + throw LogicStateAllTransitionsNotAllowed(this); + } else { + _complete(); + _machine._currentFactory = tNextFactory; + _machine._currentState = tNextState; + _machine._initState(); + } + } + + /// Completes the state with an error. + /// It will not transition to the next [LogicState], and the + /// [LogicStateMachine] will terminate as an error. + /// + /// [complete] and [completeError] can only be called once. + void completeError(Object error, [StackTrace? stackTrace]) { + assert(_initialized); + + if (!this()) { + throw LogicStateNotCurrent(this); + } + + _complete(error, stackTrace); + } + + /// Goes back to a state that has already been executed, + /// regardless of the value of its own [LogicStateTransition]. + /// + /// If the target state's [backAllowed] is false, or if there's no history, + /// it throws [LogicStateTransitionNotFound]. + void backByType(Type stateType, [Object? data]) { + assert(_initialized); + + if (!this()) { + throw LogicStateNotCurrent(this); + } + + final tHistoryIndex = _machine._stateHistories + .lastIndexWhere((v) => v.stateType == stateType && v.backAllowed); + + if (tHistoryIndex < 0) { + _complete(const LogicStateTransitionNotFound()); + } else { + final tNextFactory = + _machine._factories.firstWhereOrNull((v) => v.type == stateType); + + if (tNextFactory == null) { + // This process will not be executed if the value of _machine._stateHistories is normal. + _complete(const LogicStateTransitionNotFound()); // coverage:ignore-line + } else { + final tNextState = tNextFactory.create(); + + _complete(); + _machine._stateHistories + .removeRange(tHistoryIndex, _machine._stateHistories.length); + _machine._currentFactory = tNextFactory; + _machine._currentState = tNextState; + _machine._initState(data); + } + } + } + + /// Transitions to the state of [stateType]. + /// + /// If the type of [stateType] does not exist in its own [LogicStateTransition], + /// it throws [LogicStateTransitionNotFound]. + /// If it exists but cannot transition (for instance, if `predicate` returns false), + /// it throws [LogicStateTransitionNotAllowed]. + /// + /// [toByType] can only be called once. Also, [complete] will be executed automatically. + void toByType(Type stateType, [Object? data]) { + assert(_initialized); + + if (!this()) { + throw LogicStateNotCurrent(this); + } + + final tTransition = _machine._currentFactory._transitions + .firstWhereOrNull((v) => v.nextType == stateType); + + if (tTransition == null) { + _complete(const LogicStateTransitionNotFound()); + } else { + final tNextFactory = + _machine._factories.firstWhereOrNull((v) => v.type == stateType); + + if (tNextFactory == null) { + _complete(const LogicStateTransitionNotFound()); + } else { + final tNextState = tNextFactory.create(); + + if (!tTransition._predicate(this, tNextState)) { + _complete(LogicStateTransitionNotAllowed( + this, tNextState.._machine = _machine)); + } else { + _complete(); + _machine._currentFactory = tNextFactory; + _machine._currentState = tNextState; + _machine._initState(data); + } + } + } + } + + /// Transitions to the state of type [T]. + /// + /// If the type [T] does not exist in its own [LogicStateTransition], + /// it throws [LogicStateTransitionNotFound]. + /// If it exists but cannot transition (for instance, if `predicate` returns false), + /// it throws [LogicStateTransitionNotAllowed]. + /// + /// [to] can only be called once. Also, [complete] will be executed automatically. + void to([Object? data]) { + toByType(T, data); + } + + void _complete([Object? error, StackTrace? stackTrace]) { + try { + dispose(); + final tHistoryIndex = _machine._stateHistories + .lastIndexWhere((e) => e.stateType == runtimeType); + if (tHistoryIndex >= 0) { + _machine._stateHistories[tHistoryIndex] = + (stateType: runtimeType, backAllowed: backAllowed); + } + } finally { + if (error != null) { + _completer.completeError(error, stackTrace); + _machine._completeError(error, stackTrace); + } else { + _completer.complete(); + } + } + } + + /// Returns true if [LogicStateMachine.current] is itself. + @nonVirtual + bool call() => + !_machine.complete && + !_completer.isCompleted && + _machine._currentState == this; + + /// Executed first when the state is created. + @mustCallSuper + void init(Object? data) { + _initialized = true; + _machine._notifyListeners(); + } + + /// Called when the state processing is completed. + @mustCallSuper + void dispose() {} + + @override + String toString() => + '$runtimeType: initialized=$_initialized, active=${this()}'; +} + +/// Factory for [LogicState]. +class LogicStateFactory { + /// Creates a new [LogicState]. + final T Function() create; + final List> _transitions; + + /// Returns the [Type] of [LogicState]. + Type get type => T; + + /// Creates a new [LogicStateFactory]. + /// + /// Please specify all possible [LogicState]s that can transition from that + /// [LogicState] in [transitions]. + /// When a [LogicState] is completed, it will find the next state to execute + /// from the top of [transitions]. + /// At that time, the method (`predicate`) passed to the constructor of + /// [LogicStateTransition] is executed, and it transitions to the first [LogicState] that returns true. + /// + /// If you leave [transitions] empty, the processing of [LogicStateMachine] + /// will be completed as soon as that state is completed. + const LogicStateFactory( + this.create, + List> transitions, + ) : _transitions = transitions; +} + +/// A class that specifies the types of other [LogicState]s that can transition from a given [LogicState]. +class LogicStateTransition { + final TransitionPredicate _predicate; + + Type get nextType => R; + + /// Creates a new [LogicStateTransition]. + /// + /// If [predicate] returns false, it does not allow transitioning to that [LogicState]. + /// When a [LogicState] is completed, the next state to execute will be the + /// first state for which [predicate] returns true. + LogicStateTransition([TransitionPredicate? predicate]) + : _predicate = predicate ?? ((c, n) => true); +} + +typedef TransitionPredicate = bool Function( + LogicState current, LogicState next); + +/// The state in which [LogicStateMachine] terminated with an error. +class LogicStateError extends LogicState { + /// Error details. + final Object error; + + /// StackTrace + final StackTrace? stackTrace; + + LogicStateError(this.error, this.stackTrace); +} + +/// The state in which [LogicStateMachine] successfully completed. +class LogicStateComplete extends LogicState {} diff --git a/packages/patapata_core/lib/src/method_channel_test_mixin.dart b/packages/patapata_core/lib/src/method_channel_test_mixin.dart new file mode 100644 index 0000000..4de1ddb --- /dev/null +++ b/packages/patapata_core/lib/src/method_channel_test_mixin.dart @@ -0,0 +1,28 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import 'package:flutter/services.dart'; + +/// A mixin for adding a function to mock processing in a test environment +/// to a class. +mixin MethodChannelTestMixin { + final List methodCallLogs = []; + + /// A function for mocking a MethodChannel. + /// + /// Currently supported by [App], [Plugin] and [Config]. + /// If you override this function in a subclass that inherits from each, + /// the processing described here will be executed + /// when the constructor is called for [App], + /// and when the init function is called for [Plugin] and [Config]. + void setMockMethodCallHandler() {} + + /// A function for mocking a MockStreamHandler. + /// + /// Currently supported by [Plugin]. + /// If you override this function in a subclass that inherits from [Plugin], + /// the processing described here will be executed when the init function + /// for [Plugin] is called. + void setMockStreamHandler() {} +} diff --git a/packages/patapata_core/lib/src/native_ffi_finder.dart b/packages/patapata_core/lib/src/native_ffi_finder.dart new file mode 100644 index 0000000..b3837af --- /dev/null +++ b/packages/patapata_core/lib/src/native_ffi_finder.dart @@ -0,0 +1,59 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:ffi'; +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; + +final String _nativeLibDirectory = kIsWeb + ? '' + : defaultTargetPlatform == TargetPlatform.android + ? (() { + // TODO: This path is hardcoding. so cannot use App + final tNativeLibDirTempFile = + File('${Directory.systemTemp.path}/patapataNativeLib'); + + if (tNativeLibDirTempFile.existsSync()) { + try { + return tNativeLibDirTempFile.readAsStringSync(); + } catch (_) { + // ignore + return ''; + } + } else { + return ''; + } + })() + : ''; + +DynamicLibrary safeLoadDynamicLibrary(String library) { + if (_nativeLibDirectory.isNotEmpty) { + try { + return DynamicLibrary.open("$_nativeLibDirectory/lib$library.so"); + } catch (_) { + try { + final tLib = DynamicLibrary.open("lib$library.so"); + + return tLib; + } catch (_) { + // On some (especially old) Android devices, we somehow can't dlopen + // libraries shipped with the apk. We need to find the full path of the + // library and open that one. + // For details, see https://github.com/simolus3/moor/issues/420 + final tAppIdAsBytes = File('/proc/self/cmdline').readAsBytesSync(); + + // app id ends with the first \0 character in here. + final tEndOfAppId = max(tAppIdAsBytes.indexOf(0), 0); + final tAppId = + String.fromCharCodes(tAppIdAsBytes.sublist(0, tEndOfAppId)); + + return DynamicLibrary.open('/data/data/$tAppId/lib/lib$library.so'); + } + } + } else { + return DynamicLibrary.open("lib$library.so"); + } +} diff --git a/packages/patapata_core/lib/src/native_local_config.dart b/packages/patapata_core/lib/src/native_local_config.dart new file mode 100644 index 0000000..093857e --- /dev/null +++ b/packages/patapata_core/lib/src/native_local_config.dart @@ -0,0 +1,34 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'local_config.dart'; +import 'plugin.dart'; + +import '../finder/local_config_finder.dart' + if (dart.library.io) '../src/native_local_config_finder.dart' + if (dart.library.html) '../web/web_local_config_finder.dart'; + +/// This is an abstract class that utilizes conditional import functionality +/// to handle [LocalConfigFinder] in both the app environment and web environment +/// using a common source code. +/// In the app environment, it functions as [NativeLocalConfigFinder], +/// while in the web environment, it functions as [WebLocalConfigFinder]. +/// +/// Please refer to the reference below for information on conditional imports. +/// https://dart.dev/guides/libraries/create-library-packages#conditionally-importing-and-exporting-library-files +abstract class LocalConfigFinder { + factory LocalConfigFinder() => getLocalConfigFinder(); + LocalConfig? getLocalConfig() => null; // coverage:ignore-line +} + +final LocalConfigFinder _finder = LocalConfigFinder(); + +class NativeLocalConfigPlugin extends Plugin { + @override + String get name => 'dev.patapata.native_local_config'; + + @override + LocalConfig? createLocalConfig() => _finder.getLocalConfig(); +} diff --git a/packages/patapata_core/lib/src/native_local_config_finder.dart b/packages/patapata_core/lib/src/native_local_config_finder.dart new file mode 100644 index 0000000..30ba220 --- /dev/null +++ b/packages/patapata_core/lib/src/native_local_config_finder.dart @@ -0,0 +1,212 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +final _logger = Logger('patapata.NativeLocalConfig'); + +const _methodChannelName = 'dev.patapata.native_local_config'; +const _methodChannel = MethodChannel(_methodChannelName); + +class NativeLocalConfigFinder implements LocalConfigFinder { + @override + LocalConfig? getLocalConfig() => NativeLocalConfig(); +} + +class NativeLocalConfig extends LocalConfig with MemoryLocalConfig { + @visibleForTesting + final Map mockNativeLocalConfigMap = {}; + + @override + Future init() async { + _methodChannel.setMethodCallHandler(_onMethodCall); + await super.init(); + } + + @override + void dispose() async { + _methodChannel.setMethodCallHandler(null); + super.dispose(); + } + + Future _onMethodCall(MethodCall call) async { + switch (call.method) { + case 'syncAll': + _onSyncAll((call.arguments as Map).cast()); + break; + case 'error': + _onError((call.arguments as Map).cast()); + break; + default: + break; + } + } + + void _onSyncAll(Map data) { + store.clear(); + store.addAll(data); + notifyListeners(); + } + + void _onError(Map error) { + final tError = NativeThrowable.fromMap(error); + + _logger.severe(tError.message, tError, tError.chain); + } + + @override + Future reset(String key) async { + await super.reset(key); + await _methodChannel.invokeMethod('reset', key); + } + + @override + Future resetAll() async { + await super.resetAll(); + await _methodChannel.invokeMethod('resetAll'); + } + + @override + Future resetMany(List keys) async { + await super.resetMany(keys); + await _methodChannel.invokeMethod('resetMany', keys); + } + + @override + Future setBool(String key, bool value) async { + super.setBool(key, value); + await _methodChannel.invokeMethod('setBool', [key, value]); + } + + @override + Future setDouble(String key, double value) async { + super.setDouble(key, value); + await _methodChannel.invokeMethod('setDouble', [key, value]); + } + + @override + Future setInt(String key, int value) async { + super.setInt(key, value); + await _methodChannel.invokeMethod('setInt', [key, value]); + } + + @override + Future setString(String key, String value) async { + super.setString(key, value); + await _methodChannel.invokeMethod('setString', [key, value]); + } + + @override + Future setMany(Map objects) async { + super.setMany(objects); + await _methodChannel.invokeMethod('setMany', objects); + } + + @override + Future setDefaults(Map defaults) async { + await super.setDefaults(defaults); + } + + @override + @visibleForTesting + void setMockMethodCallHandler() { + // ignore: invalid_use_of_visible_for_testing_member + testSetMockMethodCallHandler( + _methodChannel, + (methodCall) async { + methodCallLogs.add(methodCall); + switch (methodCall.method) { + case 'reset': + final tKey = methodCall.arguments as String; + mockNativeLocalConfigMap.remove(tKey); + _onMethodCall( + MethodCall('syncAll', mockNativeLocalConfigMap), + ); + break; + case 'resetAll': + mockNativeLocalConfigMap.clear(); + _onMethodCall( + MethodCall('syncAll', mockNativeLocalConfigMap), + ); + break; + case 'resetMany': + final tKeys = List.from(methodCall.arguments); + + for (final tKey in tKeys) { + mockNativeLocalConfigMap.remove(tKey); + } + _onMethodCall( + MethodCall('syncAll', mockNativeLocalConfigMap), + ); + break; + + case 'setBool': + final tArguments = methodCall.arguments as List; + mockNativeLocalConfigMap[tArguments[0] as String] = + tArguments[1] as bool; + _onMethodCall( + MethodCall('syncAll', mockNativeLocalConfigMap), + ); + break; + case 'setDouble': + final tArguments = methodCall.arguments as List; + mockNativeLocalConfigMap[tArguments[0] as String] = + tArguments[1] as double; + _onMethodCall( + MethodCall('syncAll', mockNativeLocalConfigMap), + ); + break; + case 'setInt': + final tArguments = methodCall.arguments as List; + mockNativeLocalConfigMap[tArguments[0] as String] = + tArguments[1] as int; + _onMethodCall( + MethodCall('syncAll', mockNativeLocalConfigMap), + ); + break; + case 'setString': + final tArguments = methodCall.arguments as List; + mockNativeLocalConfigMap[tArguments[0] as String] = + tArguments[1] as String; + _onMethodCall( + MethodCall('syncAll', mockNativeLocalConfigMap), + ); + break; + case 'setMany': + final tArguments = Map.from(methodCall.arguments); + + for (final tKey in tArguments.keys) { + mockNativeLocalConfigMap[tKey] = tArguments[tKey] as Object; + } + _onMethodCall( + MethodCall('syncAll', mockNativeLocalConfigMap), + ); + break; + } + return null; + }, + ); + } + + @visibleForTesting + void sendError() { + _onMethodCall( + MethodCall( + 'error', + { + "type": runtimeType.toString(), + "message": "", + "stackTrace": const [], + "cause": const {}, + }, + ), + ); + } +} + +LocalConfigFinder getLocalConfigFinder() => NativeLocalConfigFinder(); diff --git a/packages/patapata_core/lib/src/network.dart b/packages/patapata_core/lib/src/network.dart new file mode 100644 index 0000000..43c2559 --- /dev/null +++ b/packages/patapata_core/lib/src/network.dart @@ -0,0 +1,230 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import 'app.dart'; +import 'plugin.dart'; +import 'util.dart'; + +/// This enum describes various states of network connectivity for an app or system +enum NetworkConnectivity { + /// Status representing an unknown network state + unknown, + + /// Status representing a state where there is no network connection + none, + + /// Status representing a state where there is a network connection, but the type is unknown + other, + + /// Status representing a state connected to a mobile network + mobile, + + /// Status representing a state connected to Wi-Fi + wifi, + + /// Status representing a state connected to Ethernet + ethernet, + + /// Status representing a state connected to Bluetooth + bluetooth, + + /// Status representing a state connected to a VPN network + /// + /// Note for iOS and macOS: + /// There is no separate network interface type for [vpn]. + /// It returns [other] on any device (also simulator). + vpn, +} + +/// A class representing the current network status of [App]. +/// +/// This class, within [NetworkPlugin] +/// obtains the network status using [Connectivity]. +class NetworkInformation { + final NetworkConnectivity connectivity; + + const NetworkInformation({ + required this.connectivity, + }); + + factory NetworkInformation.unknown() => const NetworkInformation( + connectivity: NetworkConnectivity.unknown, + ); + + @override + String toString() => 'NetworkInformation:connectivity=${connectivity.name}'; + + NetworkInformation copyWith({ + NetworkConnectivity? connectivity, + }) => + NetworkInformation( + connectivity: connectivity ?? this.connectivity, + ); + + @override + operator ==(Object other) => + other is NetworkInformation && connectivity == other.connectivity; + + @override + int get hashCode => Object.hash('NetworkInformation', connectivity); +} + +/// Plugin for retrieving the network status of [App]. +class NetworkPlugin extends Plugin with WidgetsBindingObserver { + NetworkPlugin(); + + final Connectivity _connectivity = Connectivity(); + + NetworkInformation _currentNetworkInformation = const NetworkInformation( + connectivity: NetworkConnectivity.unknown, + ); + + /// Return the current network status of [App]. + /// + /// ```dart + /// getApp().network.information + /// ``` + NetworkInformation get information => _currentNetworkInformation; + + final _streamController = StreamController.broadcast(); + + /// Stream to get the latest network state of the app + /// + /// Typically accessed through the app's network. + /// + /// /// ```dart + /// getApp().network.informationStream; + /// ``` + Stream get informationStream => _streamController.stream; + + StreamSubscription? _onConnectivityChangedSubscription; + + @override + FutureOr init(App app) async { + await super.init(app); + WidgetsBinding.instance.addObserver(this); + _onConnectivityChangedSubscription = + _connectivity.onConnectivityChanged.listen(_onConnectivityChanged); + _onConnectivityChanged(await _connectivity.checkConnectivity()); + + return true; + } + + @override + FutureOr dispose() async { + await super.dispose(); + WidgetsBinding.instance.removeObserver(this); + _onConnectivityChangedSubscription?.cancel(); + _onConnectivityChangedSubscription = null; + _streamController.close(); + } + + @override + Future didChangeAppLifecycleState(AppLifecycleState state) async { + if (state == AppLifecycleState.resumed) { + // When we move to the foreground, + // get the newest network information. + _onConnectivityChanged(await _connectivity.checkConnectivity()); + } + } + + @override + Widget createAppWidgetWrapper(Widget child) { + return StreamProvider( + create: (context) => informationStream, + initialData: information, + child: child, + ); + } + + void _onConnectivityChanged(ConnectivityResult event) { + NetworkConnectivity tConnectivity = switch (event) { + ConnectivityResult.none => NetworkConnectivity.none, + ConnectivityResult.other => NetworkConnectivity.other, + ConnectivityResult.mobile => NetworkConnectivity.mobile, + ConnectivityResult.wifi => NetworkConnectivity.wifi, + ConnectivityResult.ethernet => NetworkConnectivity.ethernet, + ConnectivityResult.bluetooth => NetworkConnectivity.bluetooth, + ConnectivityResult.vpn => NetworkConnectivity.vpn, + }; + + if (_currentNetworkInformation.connectivity == tConnectivity) { + return; + } + + _currentNetworkInformation = _currentNetworkInformation.copyWith( + connectivity: tConnectivity, + ); + + _streamController.add(_currentNetworkInformation); + } + + @override + @visibleForTesting + void setMockMethodCallHandler() { + // ignore: invalid_use_of_visible_for_testing_member + testSetMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (methodCall) async { + methodCallLogs.add(methodCall); + switch (methodCall.method) { + case 'check': + return testOnConnectivityChangedValue.name; + default: + break; + } + return null; + }, + ); + } + + StreamController? _mockStreamController; + Completer? _testChangeConnectivityCompleter; + + @override + @visibleForTesting + void setMockStreamHandler() { + _mockStreamController = StreamController(); + StreamSubscription? tMockStreamSubscription; + + // ignore: invalid_use_of_visible_for_testing_member + testSetMockStreamHandler( + const EventChannel('dev.fluttercommunity.plus/connectivity_status'), + TestMockStreamHandler.inline( + onListen: (data, sink) { + tMockStreamSubscription = _mockStreamController!.stream.listen((v) { + sink.success(v.name); + _testChangeConnectivityCompleter?.complete(); + _testChangeConnectivityCompleter = null; + }); + }, + onCancel: (_) { + tMockStreamSubscription?.cancel(); + _testChangeConnectivityCompleter?.complete(); + _testChangeConnectivityCompleter = null; + }, + ), + ); + } + + @visibleForTesting + Future testChangeConnectivity(NetworkConnectivity result) { + final tCompleter = _testChangeConnectivityCompleter = Completer(); + testOnConnectivityChangedValue = result; + _mockStreamController?.add(result); + + return tCompleter.future; + } +} + +@visibleForTesting +NetworkConnectivity testOnConnectivityChangedValue = NetworkConnectivity.none; diff --git a/packages/patapata_core/lib/src/notifications.dart b/packages/patapata_core/lib/src/notifications.dart new file mode 100644 index 0000000..71280ea --- /dev/null +++ b/packages/patapata_core/lib/src/notifications.dart @@ -0,0 +1,304 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:logging/logging.dart'; + +import 'app.dart'; +import 'plugin.dart'; +import 'util.dart'; +import 'exception.dart'; +import 'widgets/standard_app.dart'; + +final _logger = Logger('patapata.Notifications'); + +const _methodChannelName = 'dexterous.com/flutter/local_notifications'; +const _methodChannel = MethodChannel(_methodChannelName); + +/// This is a Mixin for adding variables related to notification settings +/// to the Environment. +/// +/// When initializing [FlutterLocalNotificationsPlugin] with [NotificationsPlugin.init] +/// you can override some settings with the Environment. +mixin NotificationsEnvironment { + String get notificationsAndroidDefaultIcon; + bool get notificationsDarwinDefaultPresentAlert; + bool get notificationsDarwinDefaultPresentSound; + bool get notificationsDarwinDefaultPresentBadge; + bool get notificationsDarwinDefaultPresentBanner; + bool get notificationsDarwinDefaultPresentList; + List get notificationsAndroidChannels; + String get notificationsPayloadLocationKey => 'location'; +} + +/// This is a plugin for performing local notifications. +/// +/// It includes the following features: +/// 1. Ability to initialize FlutterLocalNotificationsPlugin +/// separately for each operating system. +/// 2. Ability to reference the DeepLink included in the notification payload +/// and navigate to any desired screen. +class NotificationsPlugin extends Plugin with StandardAppRoutePluginMixin { + static const AndroidNotificationChannel kDefaultAndroidChannel = + AndroidNotificationChannel( + 'default', + 'Primary Channel', + importance: Importance.max, + enableLights: true, + ); + + // coverage:ignore-start + static void initializeNotificationsForBackgroundIsolate() { + try { + final tPlugin = FlutterLocalNotificationsPlugin(); + tPlugin.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + requestCriticalPermission: false, + defaultPresentAlert: true, + defaultPresentSound: true, + defaultPresentBadge: true, + ), + macOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + requestCriticalPermission: false, + defaultPresentAlert: true, + defaultPresentSound: true, + defaultPresentBadge: true, + ), + ), + ); + } catch (e, stackTrace) { + _logger.warning( + 'Failed to initialize isolate FlutterLocalNotificationsPlugin.', + NotificationsInitializationException(original: e), + stackTrace, + ); + } + } + // coverage:ignore-end + + @override + FutureOr init(App app) async { + if (!await super.init(app)) { + return false; + } + + final tEnvironment = app.environment is NotificationsEnvironment + ? app.environment as NotificationsEnvironment + : null; + + try { + final tPlugin = FlutterLocalNotificationsPlugin(); + tPlugin.initialize( + InitializationSettings( + android: AndroidInitializationSettings( + tEnvironment?.notificationsAndroidDefaultIcon ?? + '@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + requestCriticalPermission: false, + requestProvisionalPermission: false, + defaultPresentAlert: + tEnvironment?.notificationsDarwinDefaultPresentAlert ?? true, + defaultPresentSound: + tEnvironment?.notificationsDarwinDefaultPresentSound ?? true, + defaultPresentBadge: + tEnvironment?.notificationsDarwinDefaultPresentBadge ?? true, + defaultPresentBanner: + tEnvironment?.notificationsDarwinDefaultPresentBanner ?? true, + defaultPresentList: + tEnvironment?.notificationsDarwinDefaultPresentList ?? true, + ), + macOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + requestCriticalPermission: false, + requestProvisionalPermission: false, + defaultPresentAlert: + tEnvironment?.notificationsDarwinDefaultPresentAlert ?? true, + defaultPresentSound: + tEnvironment?.notificationsDarwinDefaultPresentSound ?? true, + defaultPresentBadge: + tEnvironment?.notificationsDarwinDefaultPresentBadge ?? true, + defaultPresentBanner: + tEnvironment?.notificationsDarwinDefaultPresentBanner ?? true, + defaultPresentList: + tEnvironment?.notificationsDarwinDefaultPresentList ?? true, + ), + ), + onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse, + ); + + if (defaultTargetPlatform == TargetPlatform.android) { + final tPlatform = tPlugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>()!; + + if (tEnvironment != null) { + for (var i in tEnvironment.notificationsAndroidChannels) { + await tPlatform.createNotificationChannel(i); + } + } else { + await tPlatform.createNotificationChannel(kDefaultAndroidChannel); + } + } + } catch (e, stackTrace) { + // coverage:ignore-start + _logger.warning( + 'Failed to initialize FlutterLocalNotificationsPlugin.', + NotificationsInitializationException(original: e), + stackTrace, + ); + // coverage:ignore-end + } + + return true; + } + + @override + FutureOr dispose() { + _streamController.close(); + + return super.dispose(); + } + + final _streamController = StreamController.broadcast(); + Stream get notifications => _streamController.stream; + + void _onDidReceiveNotificationResponse(NotificationResponse response) { + _logger.info('received notification: $response'); + _streamController.add(response); + } + + String get payloadLocationKey => app.environment is NotificationsEnvironment + ? (app.environment as NotificationsEnvironment) + .notificationsPayloadLocationKey + : 'location'; + + Uri? uriFromPayload(String? payload) { + if (payload?.isNotEmpty != true) { + return null; + } + + try { + final tAsUrl = Uri.tryParse(payload!); + + if (tAsUrl != null) { + return tAsUrl; + } + + final tJsonData = jsonDecode(payload) as Map; + + if (tJsonData.containsKey(payloadLocationKey)) { + return Uri.parse(tJsonData[payloadLocationKey] as String); + } + } catch (e) { + // ignore + } + + return null; + } + + @override + Future getInitialRouteData() async { + final tLaunchNotification = await FlutterLocalNotificationsPlugin() + .getNotificationAppLaunchDetails(); + + if (tLaunchNotification?.notificationResponse != null) { + if (tLaunchNotification!.didNotificationLaunchApp) { + final tPayload = + uriFromPayload(tLaunchNotification.notificationResponse!.payload); + + if (tPayload != null) { + final tRouteData = await app + .getPlugin() + ?.parser + ?.parseRouteInformation(RouteInformation(uri: tPayload)); + + if (tRouteData?.factory != null) { + return tRouteData; + } + } + } + } + + return null; + } + + /// This is a function to enable screen navigation via Deep Link + /// included in the notification payload. + /// + /// To enable this functionality, you need to call this function + /// after creating the [App] instance. + /// For example: + /// ```dart + /// void main() async { + /// final App tApp = App( + /// environment: ..., + /// createAppWidget: ..., + /// ); + /// + /// tApp.getPlugin()?.enableStandardAppIntegration(); + /// + /// tApp.run(); + /// } + /// ``` + void enableStandardAppIntegration() { + notifications.listen((event) { + if (event.payload?.isNotEmpty == true) { + app.getPlugin()?.route(event.payload!); + } + }); + } + + @visibleForTesting + void mockRecieveNotificationResponse(NotificationResponse response) { + _onDidReceiveNotificationResponse(response); + } + + @override + @visibleForTesting + void setMockMethodCallHandler() { + // ignore: invalid_use_of_visible_for_testing_member + testSetMockMethodCallHandler( + _methodChannel, + (methodCall) async { + methodCallLogs.add(methodCall); + switch (methodCall.method) { + case 'initialize': + return true; + case 'getNotificationAppLaunchDetails': + return notificationAppLaunchDetailsMap; + } + return null; + }, + ); + } +} + +@visibleForTesting +Map? notificationAppLaunchDetailsMap; + +/// Throw when exception if an error occurs in initializing [NotificationsPlugin]. +class NotificationsInitializationException extends PatapataCoreException { + const NotificationsInitializationException({ + super.original, + }) : super(code: PatapataCoreExceptionCode.PPE501); +} diff --git a/packages/patapata_core/lib/src/package_info.dart b/packages/patapata_core/lib/src/package_info.dart new file mode 100644 index 0000000..d6933ac --- /dev/null +++ b/packages/patapata_core/lib/src/package_info.dart @@ -0,0 +1,83 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'plugin.dart'; +import 'app.dart'; + +class _MockValues { + static String appName = 'mock_patapata_core'; + static String packageName = 'io.flutter.plugins.mockpatapatacore'; + static String version = '1.0'; + static String buildNumber = '1'; + static String buildSignature = 'patapata_core_build_signature'; + static String? installerStore; +} + +/// Plugin that manages information related to the application's metadata. +/// +/// This plugin calls [PackageInfo.fromPlatform] during initialization, +/// allowing subsequent synchronous access from the application or other plugins. +/// +/// This plugin is automatically created during application initialization +/// and can be accessed from [App.package]. +class PackageInfoPlugin extends Plugin { + late final PackageInfo _info; + + /// Access to information about this application's metadata. + PackageInfo get info => _info; + + /// Mocks [info] for testing purposes. + @visibleForTesting + static void setMockValues({ + required String appName, + required String packageName, + required String version, + required String buildNumber, + required String buildSignature, + String? installerStore, + }) { + _MockValues.appName = appName; + _MockValues.packageName = packageName; + _MockValues.version = version; + _MockValues.buildNumber = buildNumber; + _MockValues.buildSignature = buildSignature; + _MockValues.installerStore = installerStore; + } + + @override + FutureOr init(App app) async { + await super.init(app); + + _info = await PackageInfo.fromPlatform(); + + return true; + } + + @override + Widget createAppWidgetWrapper(Widget child) { + return Provider.value( + value: this, + child: child, + ); + } + + @override + void setMockMethodCallHandler() { + // ignore: invalid_use_of_visible_for_testing_member + PackageInfo.setMockInitialValues( + appName: _MockValues.appName, + packageName: _MockValues.packageName, + version: _MockValues.version, + buildNumber: _MockValues.buildNumber, + buildSignature: _MockValues.buildSignature, + installerStore: _MockValues.installerStore, + ); + } +} diff --git a/packages/patapata_core/lib/src/permissions.dart b/packages/patapata_core/lib/src/permissions.dart new file mode 100644 index 0000000..6ca2ffd --- /dev/null +++ b/packages/patapata_core/lib/src/permissions.dart @@ -0,0 +1,245 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:logging/logging.dart'; +import 'package:patapata_core/src/method_channel_test_mixin.dart'; +import 'package:patapata_core/src/util.dart'; + +import 'app.dart'; + +const _methodChannel = MethodChannel('dev.patapata.patapata_core'); + +final _logger = Logger('patapata.Permissions'); + +/// Result of [Permissions.requestTracking]. +enum PermissionTrackingResult { + notSupported, + authorized, + denied, + notDetermined, + restricted, +} + +/// Result of [Permissions.requestNotifications]. +enum PermissionNotificationResult { + authorized, + denied, + notDetermined, +} + +const _kLocalConfigTrackingRequested = 'patapata.Permissions:trackingRequested'; +const _kLocalConfigNotificationsRequested = + 'patapata.Permissions:notificationsRequested'; + +/// Requests permissions from the platform (Android, iOS). +/// +/// This class is automatically created at application initialization +/// and can be accessed from [App.permissions]. +class Permissions with MethodChannelTestMixin { + /// The [App] passed in the constructor. + final App app; + + Permissions({ + required this.app, + }); + + final _trackingStreamController = + StreamController.broadcast(); + final _notificationsStreamController = + StreamController.broadcast(); + + /// Returns a Stream to observe the results of [requestTracking]. + Stream get trackingStream => + _trackingStreamController.stream; + + /// Returns a Stream to observe the results of [requestNotifications]. + Stream get notificationsStream => + _notificationsStreamController.stream; + + /// If true, tracking permissions have already been requested. + bool get trackingRequested => defaultTargetPlatform != TargetPlatform.iOS + ? true + : app.localConfig.getBool(_kLocalConfigTrackingRequested); + + /// If true, notification permissions have already been requested. + bool get notificationsRequested => + app.localConfig.getBool(_kLocalConfigNotificationsRequested); + + // The values ​​of Permissions:requestTracking reflected during testing + PermissionTrackingResult? _mockPermissionTrackingResult; + + // The values ​​of requestPermission and requestPermissions reflected during testing + bool? _mockLocalNotificationsPermission; + + /// Releases each resource. + void dispose() { + _trackingStreamController.close(); + _notificationsStreamController.close(); + } + + @override + @visibleForTesting + void setMockMethodCallHandler() { + // ignore: invalid_use_of_visible_for_testing_member + testSetMockMethodCallHandler( + _methodChannel, + (methodCall) async { + switch (methodCall.method) { + case 'Permissions:requestTracking': + return _mockPermissionTrackingResult?.name; + } + return null; + }, + ); + + // ignore: invalid_use_of_visible_for_testing_member + testSetMockMethodCallHandler( + const MethodChannel('dexterous.com/flutter/local_notifications'), + (methodCall) async { + switch (methodCall.method) { + case 'requestPermission': + return _mockLocalNotificationsPermission; + case 'requestPermissions': + return _mockLocalNotificationsPermission; + } + return null; + }, + ); + } + + /// Mocks [trackingRequested] and [notificationsRequested] for testing purposes. + @visibleForTesting + void testSetRequested({ + bool? trackingRequested, + bool? notificationsRequested, + }) { + app.localConfig.setBool( + _kLocalConfigTrackingRequested, + trackingRequested ?? this.trackingRequested, + ); + + app.localConfig.setBool( + _kLocalConfigNotificationsRequested, + notificationsRequested ?? this.notificationsRequested, + ); + } + + // Mocks return values ​​of Permissions:requestTracking for testing purposes. + @visibleForTesting + void testSetPermissionTrackingResult(PermissionTrackingResult? result) { + _mockPermissionTrackingResult = result; + } + + // Mocks return values ​​of requestPermission and requestPermissions for testing purposes. + @visibleForTesting + void testSetLocalNotificationsPermission(bool? result) { + _mockLocalNotificationsPermission = result; + } + + /// Requests tracking permissions. + /// + /// For iOS, please add `NSUserTrackingUsageDescription` + /// to the application's `Info.plist`. + Future requestTracking() async { + _logger.info('requestTracking'); + + PermissionTrackingResult tTrackingResult = + PermissionTrackingResult.notSupported; + + if (defaultTargetPlatform != TargetPlatform.iOS) { + return tTrackingResult; + } + + await app.localConfig.setBool(_kLocalConfigTrackingRequested, true); + + try { + final tResult = await _methodChannel + .invokeMethod('Permissions:requestTracking'); + + if (tResult == null) { + tTrackingResult = PermissionTrackingResult.notSupported; + } else { + tTrackingResult = PermissionTrackingResult.values + .firstWhere((element) => element.name == tResult); + } + // coverage:ignore-start + } catch (e, stackTrace) { + _logger.warning('requestTracking failed', e, stackTrace); + } + // coverage:ignore-end + + _trackingStreamController.add(tTrackingResult); + + return tTrackingResult; + } + + /// Requests notification permissions. + Future requestNotifications({ + bool sound = false, + bool alert = false, + bool badge = false, + bool critical = false, + }) async { + _logger.info('requestNotifications'); + PermissionNotificationResult tNotificationResult = + PermissionNotificationResult.notDetermined; + + await app.localConfig.setBool(_kLocalConfigNotificationsRequested, true); + + try { + if (kIsWeb) { + return tNotificationResult; + } + + if (defaultTargetPlatform == TargetPlatform.android) { + final tPlatform = FlutterLocalNotificationsPlugin() + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>()!; + tNotificationResult = (await tPlatform.requestPermission() ?? true) + ? PermissionNotificationResult.authorized + : PermissionNotificationResult.denied; + } else if (defaultTargetPlatform == TargetPlatform.iOS) { + final tPlatform = FlutterLocalNotificationsPlugin() + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>()!; + tNotificationResult = (await tPlatform.requestPermissions( + sound: sound, + alert: alert, + badge: badge, + critical: critical, + ) ?? + false) + ? PermissionNotificationResult.authorized + : PermissionNotificationResult.denied; + } else if (defaultTargetPlatform == TargetPlatform.macOS) { + final tPlatform = FlutterLocalNotificationsPlugin() + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin>()!; + tNotificationResult = (await tPlatform.requestPermissions( + sound: sound, + alert: alert, + badge: badge, + critical: critical, + ) ?? + false) + ? PermissionNotificationResult.authorized + : PermissionNotificationResult.denied; + } + // coverage:ignore-start + } catch (e, stackTrace) { + _logger.warning('requestNotifications failed', e, stackTrace); + } + // coverage:ignore-end + + _notificationsStreamController.add(tNotificationResult); + + return tNotificationResult; + } +} diff --git a/packages/patapata_core/lib/src/plugin.dart b/packages/patapata_core/lib/src/plugin.dart new file mode 100644 index 0000000..ea6194f --- /dev/null +++ b/packages/patapata_core/lib/src/plugin.dart @@ -0,0 +1,257 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:patapata_core/src/method_channel_test_mixin.dart'; + +import 'local_config.dart'; +import 'remote_config.dart'; +import 'remote_messaging.dart'; +import 'app.dart'; + +/// You can inherit from this class to create your own extensions for Patapata. +abstract class Plugin with MethodChannelTestMixin { + late final App _app; + + bool _initialized = false; + bool _disposed = false; + + Plugin(); + + /// Create a [Plugin] that can be used in [App]. + /// If you want to implement a [Plugin] that you can pass directly + /// to [App.plugins], you can use this constructor without having to + /// create your own class. + factory Plugin.inline({ + String name = 'inline', + List dependencies = const [], + bool requireRemoteConfig = false, + FutureOr Function(App app)? init, + FutureOr Function()? dispose, + Widget Function(Widget child)? createAppWidgetWrapper, + RemoteConfig? Function()? createRemoteConfig, + LocalConfig? Function()? createLocalConfig, + RemoteMessaging? Function()? createRemoteMessaging, + List Function()? navigatorObservers, + }) => + InlinePlugin( + name: name, + dependencies: dependencies, + requireRemoteConfig: requireRemoteConfig, + init: init, + dispose: dispose, + createAppWidgetWrapper: createAppWidgetWrapper, + createRemoteConfig: createRemoteConfig, + createLocalConfig: createLocalConfig, + createRemoteMessaging: createRemoteMessaging, + navigatorObservers: navigatorObservers, + ); + + /// The unique name of this [Plugin]. + /// This property is referenced in various situations, such as when enabling or disabling the plugin, + /// and when enabling or disabling the mock, and when communicating with Native code. + String get name => runtimeType.toString(); + + /// The list of other plugins that this plugin depends on. + /// This property should be used to add the types of other plugins that are required for this [Plugin] to work. + /// For example, when using the FirebaseAnalyticsPlugin, you need to include FirebaseCorePlugin. + @protected + List get dependencies => const []; + + /// This property determines whether initialization should occur after the RemoteConfig system has started when set to true, + /// or before it starts when set to false. + bool get requireRemoteConfig => false; + + /// Get the RemoteConfig key name to enable or disable this plugin. + String get remoteConfigEnabledKey => + 'patapata_plugin_${name.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_')}_enabled'; + + /// The [App] referenced by the plugin. + @protected + App get app => _app; + + /// Whether this [Plugin] is initialized or not. + @nonVirtual + bool get initialized => _initialized; + + /// Get whether this [Plugin] has been disposed. + @nonVirtual + bool get disposed => _disposed; + + /// Executed when a [PatapataApp] [run]s or when a this [Plugin] is + /// added to the [PatapataApp] after [run]. + /// This may return a [Future] for asynchronous initialization. + /// Always call [super.init] before any other overridden code. + /// + /// If any of the callbacks fail for any reason, + /// the [App] will not continue to execute callbacks + /// and will execute the [onInitFailure] callback if given. + /// Otherwise, it will silently die. (Not do anything) + @mustCallSuper + FutureOr init(App app) async { + if (_initialized) { + throw StateError('$name is already initialized.'); + } + + if (!dependencies.every((t) => app.hasPlugin(t))) { + throw StateError('$name does not have all dependencies satisfied.'); + } + + if (kIsTest) { + setMockMethodCallHandler(); + setMockStreamHandler(); + } + + _app = app; + _initialized = true; + + return true; + } + + /// Disposes this [Plugin]. + /// Always call [super.dispose] before any other overridden code. + /// In general you should not call this method as [App] will do that for you. + @mustCallSuper + FutureOr dispose() async { + if (_disposed) { + throw StateError('$name is already disposed of.'); + } + + _disposed = true; + } + + /// Wraps the [Widget] that will ultimately be passed to Flutter's [runApp] function. + /// This is used when the plugin needs to wrap [App.createAppWidget] to add widgets to the [App]'s widget tree. + Widget createAppWidgetWrapper(Widget child) => child; + + /// Specify the [RemoteConfig] to register with Patapata for this plugin. + RemoteConfig? createRemoteConfig() => null; + + /// Specify the [LocalConfig] to register with Patapata for this plugin. + LocalConfig? createLocalConfig() => null; + + /// Specify the [RemoteMessaging] to register with Patapata for this plugin. + RemoteMessaging? createRemoteMessaging() => null; + + /// Get the list of [NavigatorObserver]s to pass to Patapata for this plugin. + /// This Observers list will ultimately be added to the [App.navigatorObservers] list. + List get navigatorObservers => const []; + + /// This is a function to mock patapataEnable running in native code. + /// It will only be called when [kIsTest] is true. + @visibleForTesting + void mockPatapataEnable() {} + + /// This is a function to mock patapataDisable running in native code. + /// It will only be called when [kIsTest] is true. + @visibleForTesting + void mockPatapataDisable() {} +} + +/// A [Plugin] that can define all methods in the constructor. +class InlinePlugin extends Plugin { + @override + final String name; + @override + final List dependencies; + @override + final bool requireRemoteConfig; + final FutureOr Function(App app)? _init; + final FutureOr Function()? _dispose; + final Widget Function(Widget child)? _createAppWidgetWrapper; + final RemoteConfig? Function()? _createRemoteConfig; + final LocalConfig? Function()? _createLocalConfig; + final RemoteMessaging? Function()? _createRemoteMessaging; + final List Function()? _navigatorObservers; + + /// Creates a [Plugin] that can define all methods in the constructor. + /// See [Plugin]'s various methods for details on each method. + InlinePlugin({ + required this.name, + this.dependencies = const [], + this.requireRemoteConfig = false, + FutureOr Function(App app)? init, + FutureOr Function()? dispose, + Widget Function(Widget child)? createAppWidgetWrapper, + RemoteConfig? Function()? createRemoteConfig, + LocalConfig? Function()? createLocalConfig, + RemoteMessaging? Function()? createRemoteMessaging, + List Function()? navigatorObservers, + }) : _init = init, + _dispose = dispose, + _createAppWidgetWrapper = createAppWidgetWrapper, + _createRemoteConfig = createRemoteConfig, + _createLocalConfig = createLocalConfig, + _createRemoteMessaging = createRemoteMessaging, + _navigatorObservers = navigatorObservers; + + @override + FutureOr init(App app) async { + if (_init != null) { + if (!await _init!(app)) { + return false; + } + } + + return super.init(app); + } + + @override + FutureOr dispose() async { + if (_dispose != null) { + await _dispose!(); + } + + return super.dispose(); + } + + @override + Widget createAppWidgetWrapper(Widget child) { + if (_createAppWidgetWrapper != null) { + return _createAppWidgetWrapper!(child); + } + + return super.createAppWidgetWrapper(child); + } + + @override + RemoteConfig? createRemoteConfig() { + if (_createRemoteConfig != null) { + return _createRemoteConfig!(); + } + + return super.createRemoteConfig(); + } + + @override + LocalConfig? createLocalConfig() { + if (_createLocalConfig != null) { + return _createLocalConfig!(); + } + + return super.createLocalConfig(); + } + + @override + RemoteMessaging? createRemoteMessaging() { + if (_createRemoteMessaging != null) { + return _createRemoteMessaging!(); + } + + return super.createRemoteMessaging(); + } + + @override + List get navigatorObservers { + if (_navigatorObservers != null) { + return _navigatorObservers!(); + } + + return super.navigatorObservers; + } +} diff --git a/packages/patapata_core/lib/src/provider_model.dart b/packages/patapata_core/lib/src/provider_model.dart new file mode 100644 index 0000000..25b19dc --- /dev/null +++ b/packages/patapata_core/lib/src/provider_model.dart @@ -0,0 +1,672 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +import 'exception.dart'; +import 'sequential_work_queue.dart'; + +typedef ProviderModelProcess = FutureOr Function( + ProviderModelBatch batch); + +class _DebugDynamicType { + const _DebugDynamicType(); + + Type get type => T; +} + +/// A variable that can be used in batches and automatically +/// detect changes as to notify listeners when it's +/// value changes. +/// The variable can be 'set' or 'unset' depending on whether +/// it's value has been set or not. +/// If it's value has not been set and it is accessed, an +/// assertion will be thrown. +class ProviderModelVariable with ChangeNotifier { + T? _value; + final ProviderModel _parent; + bool _set; + final bool _nullable; + + /// Whether this variable has been set or not. + bool get set => _set; + + ProviderModelVariable._internalCreate( + this._parent, + this._value, + this._set, + ) : _nullable = null is T; + + @override + String toString() { + return '$T:${set ? unsafeValue : 'NOT SET'}'; + } + + /// The type of this variable. Runtime access to [T]. + Type get type => T; + + /// The value of this variable. If [set] is false, an assertion will be thrown. + /// Usually you should use [getValue] instead of this. + /// + /// Access to this variable via this function may not provide the newest value if it is accessed while a batch is ongoing. + /// This is why it is named 'unsafe' as a reminder to use [getValue] instead or provide getters to access this in safe situations. + T get unsafeValue { + assert(set, 'ProviderModelVariable of type $T was used before it was set.'); + + final tSubscription = Zone.current[#providerModelUseSubscription]; + + if (tSubscription != null) { + (tSubscription as ProviderModelUseSubscription)._variables.add(this); + } + + if (_nullable) { + return _value as T; + } else { + return _value!; + } + } + + bool _setValue(T newValue, [bool notify = true]) { + final tPreviousSet = _set; + _set = true; + + if (_value == newValue) { + if (notify && _set != tPreviousSet) { + notifyListeners(); + + return true; + } + + return false; + } + + _value = newValue; + + if (notify) { + notifyListeners(); + } + + return true; + } + + /// Get the current value of this variable. + /// This will wait until all currently queued batches + /// have completed before returning the value. + /// + /// You cannot call this from inside a [ProviderModel.lock] callback. + Future getValue() async { + if (_parent._lockQueues == null) { + return unsafeValue; + } + + assert( + _parent._lockQueues?.values.every((e) => e.insideSequentialWorkQueue) != + true, + 'getValue() cannot be called inside a lock() callback', + ); + + while (true) { + if (_parent.disposed) { + return unsafeValue; + } + + final tQueuesToWaitFor = + _parent._lockQueues!.values.where((e) => e.isNotEmpty); + + if (tQueuesToWaitFor.isEmpty) { + return unsafeValue; + } + + // Wait until all queues have been finished. + await Future.wait([ + for (var i in tQueuesToWaitFor) i.add(() => null), + ]); + + // But now, after waiting once, there might still be + // more things added to the queues again. + // So we loop back and check all over again. + } + } +} + +/// A batch of changes to a [ProviderModel]. +/// Changes made to [ProviderModelVariable]s will not be reflected until [commit] is called. +/// +/// If [cancel] is called, all changes made to [ProviderModelVariable]s will be discarded. +/// +/// If [commit] is called, all changes made to [ProviderModelVariable]s will be applied, +/// and [notifyListeners] will be called for anyone listening to +/// the [ProviderModel] or any [ProviderModelVariable]s. +/// +/// A batch can be created by calling [ProviderModel.begin] or [ProviderModel.lock]. +class ProviderModelBatch { + ProviderModelBatch._internalCreate(this._model); + + final ProviderModel _model; + final _reserves = {}; + + var _valid = true; + var _completed = false; + var _disabled = false; + + /// Whether this batch is valid or not. + /// A batch is valid from the time it is created forever unless [cancel] is called + /// or the batch was disabled by overridding or other means. + bool get valid => _valid && !_model.disposed; + + /// Whether this batch is complete or not. + /// A batch is completed once [commit] or [cancel] is called. + bool get completed => _completed; + + /// Set the value of a [ProviderModelVariable] in this batch. + /// All subsequent calls to [get] will return [value] instead + /// of the [ProviderModelVariable.unsafeValue] of the variable. + void set(ProviderModelVariable container, T value) { + _reserves[container] = value; + } + + /// Whether a [ProviderModelVariable] has been set itself or in this batch. + bool isSet(ProviderModelVariable container) => + _reserves.containsKey(container) || container.set; + + /// Get the value of a [ProviderModelVariable] in this batch. + /// If the value has not been set in this batch, the actual value of the variable + /// will be returned via [ProviderModelVariable.unsafeValue]. + T get(ProviderModelVariable container) => + _reserves.containsKey(container) + ? _reserves[container] + : container.unsafeValue; + + /// Disable this batch. + /// This is only called externally by [ProviderModel.lock]. + void _disable() { + _valid = false; + _disabled = true; + } + + /// Cancel this batch. + /// Once cancelled, all changes made to [ProviderModelVariable]s will be discarded. + /// + /// Any further calls to [commit], or [cancel] will be ignored. + void cancel() { + if (_completed) { + return; + } + + _valid = false; + _completed = true; + _reserves.clear(); + _model._onBatchComplete(false, false, false); + } + + /// Commit this batch. + /// Once committed, all changes made to [ProviderModelVariable]s will be applied. + /// + /// Any further calls to [commit], or [cancel] will be ignored. + /// + /// If [notify] is null (the default), [notifyListeners] will be called on the [ProviderModel] + /// as well as any [ProviderModelVariable]s whose value changed in this batch. + /// + /// If [notify] is true, [notifyListeners] will be called on the [ProviderModel] + /// as well as all [ProviderModelVariable]s in this batch. + /// + /// If [notify] is false, [notifyListeners] will not be called on the [ProviderModel] nor any [ProviderModelVariable]s. + void commit({ + bool? notify, + }) { + if (_completed) { + return; + } + + _completed = true; + + var tChanged = false; + + if (!_disabled && !_model.disposed) { + for (var i in _reserves.entries) { + tChanged = i.key._setValue(i.value, notify != false) || tChanged; + } + } + + _reserves.clear(); + _model._onBatchComplete(!_disabled, tChanged, !_disabled ? notify : false); + } + + /// Prevent execution of [f] to be overridden and cancelled by a [ProviderModel.lock] call. + /// + /// This is useful when you want to execute a function that executes an + /// external API that doesn't support it's Zone's code all of the sudden being stopped. + /// + /// In general, you should use this when calling network-based or database-based APIs. + Future blockOverride(FutureOr Function() f) async { + final tResult = await runZoned(f); + + // When the lock is overridden, this scheduled microtask will stop execution in our custom Zone. + await Future.microtask(() => null); + + return tResult; + } +} + +/// Use an instance of this class to pass in to [ProviderModel.lock] and [ProviderModel.begin]. +class ProviderLockKey { + ProviderLockKey(this.name, {this.conflictReason}); + + final String name; + final String? conflictReason; +} + +/// Thrown when [ProviderModel.begin] is called while a lock is already in place. +class ConflictException extends PatapataCoreException { + const ConflictException(this.key, this.from) + : super(code: PatapataCoreExceptionCode.PPE201); + + /// The [ProviderLockKey] that was being used when this exception was thrown. + final ProviderLockKey key; + + /// The [ProviderModel] that threw this exception. + final ProviderModel from; + + @override + String toString() { + final tReason = key.conflictReason; + + if (tReason == null) { + return 'ConflictException: ${from.runtimeType}(${key.name}#${key.hashCode}), Reason: Another batch was attempted to be created but lock() was called and commit() or cancel() have not been yet.'; + } + + return 'ConflictException: ${from.runtimeType}(${key.name}#${key.hashCode}), Reason: $tReason'; + } +} + +/// [ProviderModel] is an abstract class that can be extended to create a model for state management +/// and safe logic flow for your application. +/// It uses the [ChangeNotifier] mixin to notify its listeners when changes occur, and therefore +/// can be used with [Provider] via [ChangeNotifierProvider], and anything that can work with [ChangeNotifier] or [Listenable]. +/// +/// It provides methods for creating and managing variables ([ProviderModelVariable]), +/// [lock]ing the model for safe modifications, and subscribing to changes via [addListener] or [use]. +/// +/// The generic parameter [T] should be the type of the class that extends [ProviderModel]. +/// +/// Example usage: +/// ```dart +/// class MyClass extends ProviderModel { +/// late final _myVariable = createVariable(0); +/// +/// int get myVariable => _myVariable.unsafeValue; +/// +/// void increment() { +/// // This will increment _myVariable by 1. +/// // If this function is called multiple times at the same time, +/// // the value will be incremented 1 by 1 sequentially. +/// lock((batch) async { +/// // Do some long running API call. +/// await longApiCall(); +/// batch.set(_myVariable, batch.get(_myVariable) + 1); +/// batch.commit(); +/// }); +/// } +/// } +/// +/// // Some build function of some widget with MyClass being provided +/// // via ChangeNotifierProvider. +/// Widget build(BuildContext context) { +/// final tValue = context.watch().myVariable; +/// +/// return TextButton( +/// onPressed: () { +/// context.read().increment(); +/// }, +/// child: Text('$tValue'), +/// ); +/// } +/// ``` +abstract class ProviderModel with ChangeNotifier { + final ProviderLockKey _defaultLockKey = ProviderLockKey('BatchLockKey'); + + Map? _lockQueues; + final _variables = []; + List>? _useSubscriptions; + + bool _disposed = false; + + /// Whether this [ProviderModel] has been disposed or not. + bool get disposed => _disposed; + + bool? _debugValidType; + + /// Dipose this [ProviderModel]. + /// This will call [dispose] on all [ProviderModelVariable]s created by this [ProviderModel]. + /// This will also cancel all [ProviderModelUseSubscription]s created by this [ProviderModel]. + /// + /// This will also cancel all locks created by this [ProviderModel] asynchronously but as fast as possible. + /// If someone tries to use this model after this point inside a batch, it will throw an exception for new operations + /// and cleanly fail for existing operations by returning false from things like [lock]. + @override + @mustCallSuper + void dispose() { + assert(!_disposed, 'ProviderModel was already disposed.'); + + _disposed = true; + + for (var i in _variables) { + i.dispose(); + } + + final tUseSubscriptions = _useSubscriptions; + + if (tUseSubscriptions != null) { + for (var i in tUseSubscriptions.toList(growable: false)) { + i.cancel(); + } + + _useSubscriptions = null; + } + + super.dispose(); + + // This has no guaruntee to synchronously cancel all locks. + // Therefore we execute this at the end. + // If someone tries to use this model after this point inside a batch, it will throw an exception. + if (_lockQueues != null) { + for (var i in _lockQueues!.values) { + i.clear(); + } + + _lockQueues = null; + } + } + + /// Whether this [ProviderModel] is locked with [lockKey] or not. + bool locked(ProviderLockKey lockKey) { + if (_lockQueues == null) { + return false; + } + + return _lockQueues![lockKey]?.isNotEmpty == true; + } + + /// Create a [ProviderModelVariable] belonging to this [ProviderModel] + /// that can be used in batches and automatically + /// detect changes to notify listeners when it's + /// value changes. + /// + /// [initial] is the initial value that this variable will be set to. + /// + /// Use this function as the value of a late final variable + /// in your class. + /// + /// ```dart + /// class MyClass extends ProviderModel { + /// late final myVariable = createVariable(0); + /// } + /// ``` + @nonVirtual + @protected + ProviderModelVariable createVariable(U initial) { + assert( + _debugValidType ??= T != const _DebugDynamicType().type, + 'ProviderModel\'s T type parameter must be the same as the class that extends it. Currrently it is dynamic or unset.', + ); + + final tContainer = ProviderModelVariable._internalCreate( + this, + initial, + true, + ); + + _variables.add(tContainer); + + return tContainer; + } + + /// Creates an unset [ProviderModelVariable] belonging to this [ProviderModel] + /// that can be used in batches and automatically + /// detect changes to notify listeners when it's + /// value changes. + /// + /// When a variable is 'unset', it's value cannot be accessed + /// until it is set via a [ProviderModelBatch.set] and [ProviderModelBatch.commit] + /// or an assertion with be thrown. + /// + /// Example: + /// ```dart + /// class MyClass extends ProviderModel { + /// late final myVariable = createUnsetVariable(); + /// } + /// + /// final model = MyClass(); + /// + /// model.myVariable.unsafeValue; // assertion error + /// + /// model.begin() + /// ..set(model.myVariable, 3) + /// ..commit(); + /// + /// model.myVariable.unsafeValue; // 3 + /// ``` + @nonVirtual + @protected + ProviderModelVariable createUnsetVariable() { + assert( + _debugValidType ??= T != const _DebugDynamicType().type, + 'ProviderModel\'s T type parameter must be the same as the class that extends it. Currrently it is dynamic or unset.', + ); + + final tContainer = ProviderModelVariable._internalCreate( + this, + null, + false, + ); + + _variables.add(tContainer); + + return tContainer; + } + + void _onBatchComplete(bool committed, bool variablesChanged, bool? notify) { + if (disposed) { + return; + } + + if (committed && notify != false) { + if (variablesChanged || notify == true) { + notifyListeners(); + } + } + } + + /// Create a batch to safely modify this [ProviderModel]'s [ProviderModelVariable] members. + /// + /// A [lockKey] can be passed to prevent multiple batches from being created at the same time + /// with the same [lockKey]. + /// If a batch is already in progress with the same [lockKey], a [ConflictException] will be thrown. + /// Every [ProviderModel] has a default [ProviderLockKey] that this function will default to + /// if [lockKey] is not set. + @nonVirtual + ProviderModelBatch begin([ProviderLockKey? lockKey]) { + assert(!_disposed, 'ProviderModel was already disposed.'); + assert( + Zone.current[#providerModelUseSubscription] == null, + 'begin() cannot be called while a use callback is being executed.', + ); + + lockKey ??= _defaultLockKey; + + if (locked(lockKey)) { + throw ConflictException(lockKey, this); + } + + return ProviderModelBatch._internalCreate(this); + } + + /// Queue up to lock this [ProviderModel] to safely modify it's [ProviderModelVariable] members. + /// The [process] passed here is guarunteed to be executed sequentially and non-parallel to other proccesses passed to [lock]. + /// + /// If [override] is true, all previously queued [lock] calls with [overridable] or [onOverride] set will be cancelled. + /// If not started yet, it will be skipped, if started, it will stop execution on the next asynchronous tick. + /// When cancelled, no notifications will be sent even if [ProviderModelBatch.commit] is executed. + /// if [override] is false, process won't be called until all previous [lock]s have been completed. + /// + /// Returns a bool signifying whether execution of [process] completed without erroring, overriding, or cancelling. + /// + /// A [lockKey] can be passed to prevent multiple batches from being created at the same time + /// with the same [lockKey]. + /// Every [ProviderModel] has a default [ProviderLockKey] that this function will default to + /// if [lockKey] is not set. + @nonVirtual + Future lock( + ProviderModelProcess process, { + ProviderLockKey? lockKey, + bool override = false, + bool overridable = false, + FutureOr Function()? onOverride, + }) async { + assert(!_disposed, 'ProviderModel was already disposed.'); + assert( + Zone.current[#providerModelUseSubscription] == null, + 'lock() cannot be called while a use callback is being executed.', + ); + + lockKey ??= _defaultLockKey; + _lockQueues ??= {}; + + final tQueue = + _lockQueues!.putIfAbsent(lockKey, () => SequentialWorkQueue()); + + if (override) { + tQueue.clear(); + } + + final tBatch = ProviderModelBatch._internalCreate(this); + + return await tQueue.add( + () async { + await process(tBatch); + + return tBatch.valid && !disposed; + }, + () async { + if (onOverride != null) { + tBatch._disable(); + await onOverride(); + + return true; + } else if (overridable) { + tBatch._disable(); + + return true; + } + + return false; + }, + ) == + true; + } + + /// Subscribe to changes to this [ProviderModel]. + /// The [callback] passed here will be called whenever a [ProviderModelVariable] belonging to this [ProviderModel] + /// that was accessed inside the [callback] changes. + /// The [callback] will be called immediately once during execution of this function synchronously. + /// + /// Do not call [use] inside of [callback]. + /// Do not do any sort of asynchronous work inside of [callback]. + /// Do not start any [ProviderModelBatch]es inside of [callback] via [begin] or [lock]. + /// It should be used only to access data and not modify it. + ProviderModelUseSubscription use(void Function(T model) callback) { + assert(!_disposed, 'ProviderModel was already disposed.'); + assert( + Zone.current[#providerModelUseSubscription] == null, + 'use() cannot be called while a use callback is being executed.', + ); + + final tSubscription = ProviderModelUseSubscription._(this, callback); + + (_useSubscriptions ??= >[]) + .add(tSubscription); + + _executeUseSubscription(tSubscription); + + return tSubscription; + } + + void _executeUseSubscription(ProviderModelUseSubscription subscription) { + subscription._clearVariables(); + + runZoned( + () { + subscription.callback(this as T); + }, + zoneValues: { + #providerModelUseSubscription: subscription, + }, + ); + + subscription._listenToVariables(); + } + + void _cancelUseSubscription(ProviderModelUseSubscription subscription) { + subscription._clearVariables(); + _useSubscriptions?.remove(subscription); + } +} + +/// [ProviderModelUseSubscription] is a class that represents a subscription to a [ProviderModel]'s [ProviderModelVariable]s. +/// +/// It keeps track of the variables that the subscription is interested in and calls a callback function whenever any of these variables change. +/// +/// This class is not meant to be instantiated directly. Instead, use [ProviderModel.use]. +/// +/// Example usage: +/// ```dart +/// final subscription = myModel.use((model) { +/// print(model.myVariable.unsafeValue); +/// }); +/// ``` +final class ProviderModelUseSubscription { + final ProviderModel _model; + final _variables = {}; + + /// The callback function to be called whenever any of the variables change. + final void Function(T model) callback; + + bool _scheduled = false; + + /// Private constructor. Use [ProviderModel.use] to create an instance. + ProviderModelUseSubscription._(this._model, this.callback); + + void _clearVariables() { + for (var i in _variables) { + i.removeListener(_onVariablesChanged); + } + + _variables.clear(); + } + + void _listenToVariables() { + for (var i in _variables) { + i.addListener(_onVariablesChanged); + } + } + + void _onVariablesChanged() { + if (!_scheduled) { + _scheduled = true; + + scheduleMicrotask(() { + _scheduled = false; + _model._executeUseSubscription(this); + }); + } + } + + /// Cancels the subscription. + /// Stops listening to all variables and removes the subscription from the [ProviderModel]. + void cancel() { + _model._cancelUseSubscription(this); + } +} diff --git a/packages/patapata_core/lib/src/remote_config.dart b/packages/patapata_core/lib/src/remote_config.dart new file mode 100644 index 0000000..0ed09eb --- /dev/null +++ b/packages/patapata_core/lib/src/remote_config.dart @@ -0,0 +1,340 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'config.dart'; + +/// Abstract class for the [RemoteConfig]. +/// +/// Inherit this class to create the [RemoteConfig] class. +abstract class RemoteConfig extends Config with ReadableConfig { + /// Fetches configuration data from a remote server. + /// + /// [expiration] is the cache expiration time. Default is 5 hours. + /// If [force] is true, it ignores the cache and fetches directly from the server. + Future fetch({ + Duration expiration = const Duration(hours: 5), + bool force = false, + }); +} + +/// Class for managing all [RemoteConfig] used by the app. +/// +/// Access to [RemoteConfig] is usually done through this class. +/// example +/// ```dart +/// getApp().remoteConfig.getBool('key'); +/// ``` +class ProxyRemoteConfig extends RemoteConfig { + /// A list of [RemoteConfig] instances managed by this class. + /// These instances are used to fetch and store configuration data from a remote server. + final List _remoteConfigs = []; + + /// A map of default configuration values. + /// These values are used when no other values are specified for the corresponding keys. + final Map _defaults = {}; + + ProxyRemoteConfig(); + + /// Adds [RemoteConfig] passed as an argument to the list of managed configs. + /// + /// When initializing [Plugin], associated remote configs are added through this function. + /// Please make sure not to add [ProxyRemoteConfig]. + Future addRemoteConfig(RemoteConfig remoteConfig) async { + await remoteConfig.init(); + await remoteConfig.setDefaults(Map.of(_defaults)); + remoteConfig.addListener(onChange); + _remoteConfigs.add(remoteConfig); + + if (initialized) { + await remoteConfig.fetch(); + } + } + + /// Removes [RemoteConfig] passed as an argument from the list of managed configs. + /// + /// When dispose the [Plugin], associated remote configs are removed through this function. + void removeRemoteConfig(RemoteConfig remoteConfig) { + if (_remoteConfigs.remove(remoteConfig)) { + remoteConfig.dispose(); + } + } + + /// Disposes all the [RemoteConfig] instances managed by this class. + @override + void dispose() { + for (var v in _remoteConfigs) { + v.dispose(); + } + _remoteConfigs.clear(); + + super.dispose(); + } + + /// Fetch data from all RemoteConfig's managed by the app. + @override + Future fetch({ + Duration expiration = const Duration(hours: 5), + bool force = false, + }) async { + await Future.wait([ + for (var tConfig in _remoteConfigs) + tConfig.fetch( + expiration: expiration, + force: force, + ), + ]).catchError((e) => const []); + } + + /// Checks if the given [key] exists in any of the [RemoteConfig] instances. + @override + bool hasKey(String key) => _remoteConfigs.any((v) => v.hasKey(key)); + + /// Retrieve a bool value from [RemoteConfig] + /// + /// Gets a bool value from RemoteConfig's managed by [App]. + /// If multiple classes have the same [key], + /// it returns the value from [RemoteConfig] that was added first to [_remoteConfigs]. + /// If the [key] does not exist in any RemoteConfig's, it returns the value in [_defaults]. + /// If that also doesn't exist, it returns the [defaultValue]. + @override + bool getBool(String key, {bool defaultValue = Config.defaultValueForBool}) { + for (var tConfig in _remoteConfigs) { + if (tConfig.hasKey(key)) { + return tConfig.getBool(key, defaultValue: defaultValue); + } + } + + if (_defaults.containsKey(key) && _defaults[key] is bool) { + return _defaults[key] as bool; + } + + return defaultValue; + } + + /// Retrieve a double value from [RemoteConfig] + /// + /// Gets a double value from RemoteConfig's managed by [App]. + /// If multiple classes have the same [key], + /// it returns the value from [RemoteConfig] that was added first to [_remoteConfigs]. + /// If the [key] does not exist in any RemoteConfig's, it returns the value in [_defaults]. + /// If that also doesn't exist, it returns the [defaultValue]. + @override + double getDouble(String key, + {double defaultValue = Config.defaultValueForDouble}) { + for (var tConfig in _remoteConfigs) { + if (tConfig.hasKey(key)) { + return tConfig.getDouble(key, defaultValue: defaultValue); + } + } + + if (_defaults.containsKey(key) && _defaults[key] is double) { + return _defaults[key] as double; + } + + return defaultValue; + } + + /// Retrieve a int value from [RemoteConfig] + /// + /// Gets a int value from RemoteConfig's managed by [App]. + /// If multiple classes have the same [key], + /// it returns the value from [RemoteConfig] that was added first to [_remoteConfigs]. + /// If the [key] does not exist in any RemoteConfig's, it returns the value in [_defaults]. + /// If that also doesn't exist, it returns the [defaultValue]. + @override + int getInt(String key, {int defaultValue = Config.defaultValueForInt}) { + for (var tConfig in _remoteConfigs) { + if (tConfig.hasKey(key)) { + return tConfig.getInt(key, defaultValue: defaultValue); + } + } + + if (_defaults.containsKey(key) && _defaults[key] is int) { + return _defaults[key] as int; + } + + return defaultValue; + } + + /// Retrieve a String value from [RemoteConfig] + /// + /// Gets a String value from RemoteConfig's managed by [App]. + /// If multiple classes have the same [key], + /// it returns the value from [RemoteConfig] that was added first to [_remoteConfigs]. + /// If the [key] does not exist in any RemoteConfig's, it returns the value in [_defaults]. + /// If that also doesn't exist, it returns the [defaultValue]. + @override + String getString(String key, + {String defaultValue = Config.defaultValueForString}) { + for (var tConfig in _remoteConfigs) { + if (tConfig.hasKey(key)) { + return tConfig.getString(key, defaultValue: defaultValue); + } + } + + if (_defaults.containsKey(key) && _defaults[key] is String) { + return _defaults[key] as String; + } + + return defaultValue; + } + + /// Sets the default values for the [RemoteConfig] instances managed by this class. + /// + /// The [defaults] parameter is a map containing the default key-value pairs. + /// These values are used when no other values are specified for the corresponding keys. + /// + /// This method is asynchronous and applies the default values to all [RemoteConfig] instances. + @override + Future setDefaults(Map defaults) async { + _defaults + ..clear() + ..addAll(defaults); + + // After we add, we hint to the actual + // RemoteConfig's about the defaults as well. + // They are expected to only return true for hasKey + // when not using default values. + for (var tConfig in _remoteConfigs) { + await tConfig.setDefaults(defaults); + } + } +} + +/// A [RemoteConfig] that can be used for testing. +/// This class is not intended to be used in production. +/// This [RemoteConfig] also implements [WritableConfig] and therefore +/// can be used to set values for testing. +class MockRemoteConfig extends RemoteConfig implements WritableConfig { + final Map _store; + final Map _defaults = {}; + bool _firstFetch = true; + + /// Creates a [MockRemoteConfig] that can be used for testing. + MockRemoteConfig(Map store) : _store = store; + + @override + Future fetch({ + Duration expiration = const Duration(hours: 5), + bool force = false, + }) async { + if (force || _firstFetch) { + _firstFetch = false; + _store.addAll(_mockfetchValues); + notifyListeners(); + } + } + + @override + bool getBool(String key, {bool defaultValue = Config.defaultValueForBool}) { + return _store.containsKey(key) && _store[key] is bool + ? _store[key] as bool + : _defaults.containsKey(key) && _defaults[key] is bool + ? _defaults[key] as bool + : defaultValue; + } + + @override + double getDouble(String key, + {double defaultValue = Config.defaultValueForDouble}) { + return _store.containsKey(key) && _store[key] is double + ? _store[key] as double + : _defaults.containsKey(key) && _defaults[key] is double + ? _defaults[key] as double + : defaultValue; + } + + @override + int getInt(String key, {int defaultValue = Config.defaultValueForInt}) { + return _store.containsKey(key) && _store[key] is int + ? _store[key] as int + : _defaults.containsKey(key) && _defaults[key] is int + ? _defaults[key] as int + : defaultValue; + } + + @override + String getString(String key, + {String defaultValue = Config.defaultValueForString}) { + return _store.containsKey(key) && _store[key] is String + ? _store[key] as String + : _defaults.containsKey(key) && _defaults[key] is String + ? _defaults[key] as String + : defaultValue; + } + + @override + bool hasKey(String key) { + return _store.containsKey(key); + } + + @override + Future setDefaults(Map defaults) async { + _defaults + ..clear() + ..addAll(defaults); + } + + @override + Future reset(String key) async { + _store.remove(key); + } + + @override + Future resetAll() async { + _store.clear(); + } + + @override + Future resetMany(List keys) async { + for (var tKey in keys) { + _store.remove(tKey); + } + } + + @override + Future setBool(String key, bool value) async { + if (_store[key] != value) { + _store[key] = value; + notifyListeners(); + } + } + + @override + Future setDouble(String key, double value) async { + if (_store[key] != value) { + _store[key] = value; + notifyListeners(); + } + } + + @override + Future setInt(String key, int value) async { + if (_store[key] != value) { + _store[key] = value; + notifyListeners(); + } + } + + @override + Future setString(String key, String value) async { + if (_store[key] != value) { + _store[key] = value; + notifyListeners(); + } + } + + @override + Future setMany(Map objects) async { + _store.addAll(objects); + notifyListeners(); + } + + void testSetMockFetchValues(Map values) { + _mockfetchValues = values; + } + + Map _mockfetchValues = {}; +} diff --git a/packages/patapata_core/lib/src/remote_messaging.dart b/packages/patapata_core/lib/src/remote_messaging.dart new file mode 100644 index 0000000..f5e5e88 --- /dev/null +++ b/packages/patapata_core/lib/src/remote_messaging.dart @@ -0,0 +1,337 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; + +import 'app.dart'; + +final _logger = Logger('patapata.RemoteMessaging'); + +/// Class that represents remote messages that were displayed in the notification area of the device. +class RemoteMessageNotification { + /// Title of the notification message. + final String? title; + + /// Body of the notification message. + final String? body; + + /// Create a [RemoteMessageNotification] + /// [title] represents the title of the notification, + /// and [body] is the content of the message was shown in the notification. + const RemoteMessageNotification({ + this.title, + this.body, + }); + + @override + String toString() { + return 'RemoteMessageNotification:{title:$title, body:$body}'; + } +} + +/// A class for handling remote messages such as Patapata notifications. +class RemoteMessage { + /// The default channel name used for remote messages. + static const String kRemoteMessageDefaultChannel = 'patapata_default_channel'; + + /// A unique ID to identify the message. + final String? messageId; + + /// The name of the notification channel. + final String channel; + + /// A map of received data in the remote message. + final Map? data; + + /// The remote message for the notification. + final RemoteMessageNotification? notification; + + /// Create a [RemoteMessage] + /// [messageId] is the ID to identify the message, [channel] is the name of the notification channel with the default being [kRemoteMessageDefaultChannel], + /// [data] is the received data, and [notification] is passed for the remote message of the notification. + const RemoteMessage({ + this.messageId, + this.channel = kRemoteMessageDefaultChannel, + this.data, + this.notification, + }); + + /// Strings for the [messageId], [channel], and [data] of the remote message. + @override + String toString() { + return 'RemoteMessage:{messageId:$messageId, channel:$channel, data:$data}'; + } +} + +/// An abstract class that provides the ability to monitor state changes of [RemoteMessage]. +/// For each feature that provides remote messages, create a [RemoteMessaging] that inherits from this class. +abstract class RemoteMessaging extends ChangeNotifier { + static const _kLocalConfigKeyPrefix = 'patapata.RemoteMessaging.listeningTo:'; + + late final App _app; + + /// The [App] referenced by the plugin. + App get app => _app; + + /// Initializes the [RemoteMessaging]. + @mustCallSuper + Future init(App app) async { + _app = app; + } + + /// Disposes of the [RemoteMessaging] object. See [ChangeNotifier.dispose] for details. + @override + @mustCallSuper + void dispose() { + super.dispose(); + } + + /// Future for the message when the app is opened via Remote Message. + Future getInitialMessage(); + + /// A Stream for observing the results of notified messages [messages]. + Stream get messages; + + /// A Stream for observing the results of the notified tokens. + Stream get tokens; + + /// Obtains a token to identify the app's installation. + /// This token can be used when you want to send notifications from other services to your own app. + Future getToken(); + + /// Whether the channel name [channel] is registered to for receiving messages. + @mustCallSuper + FutureOr listenChannel(String channel) async { + if (listeningToChannel(channel)) { + return false; + } + + await _app.localConfig.setBool('$_kLocalConfigKeyPrefix$channel', true); + + return true; + } + + /// Ignore the channel name [channel] to stop receiving messages from this channel. + @mustCallSuper + FutureOr ignoreChannel(String channel) async { + if (!listeningToChannel(channel)) { + return false; + } + + await _app.localConfig.reset('$_kLocalConfigKeyPrefix$channel'); + + return true; + } + + /// Determine whether the channel name [channel] is registered to for receiving messages. + bool listeningToChannel(String channel) => + _app.localConfig.getBool('$_kLocalConfigKeyPrefix$channel'); +} + +/// Class for managing all [RemoteMessaging] used by the app. +/// +/// Access to [RemoteMessaging] is usually done through this class. +/// example +/// ```dart +/// getApp().remoteMessaging.getInitialMessage(); +/// ``` +class ProxyRemoteMessaging extends RemoteMessaging { + final Map> _subscriptions = + {}; + final Map> _tokenSubscriptions = + {}; + final List _remoteMessagings = []; + + final _messagesController = StreamController.broadcast(); + final _tokenController = StreamController.broadcast(); + + /// Create a [ProxyRemoteMessaging] + ProxyRemoteMessaging(); + + /// Add [remoteMessaging] to the [RemoteMessaging]s managed by [ProxyRemoteMessaging]. + Future addRemoteMessaging(RemoteMessaging remoteMessaging) async { + await remoteMessaging.init(_app); + _subscriptions[remoteMessaging] = + remoteMessaging.messages.listen(_onMessage); + _tokenSubscriptions[remoteMessaging] = + remoteMessaging.tokens.listen(_onToken); + _remoteMessagings.add(remoteMessaging); + } + + /// Remove [remoteMessaging] from the [RemoteMessaging]s managed by [ProxyRemoteMessaging]. + void removeRemoteMessaging(RemoteMessaging remoteMessaging) { + if (_remoteMessagings.remove(remoteMessaging)) { + _subscriptions.remove(remoteMessaging)?.cancel(); + _tokenSubscriptions.remove(remoteMessaging)?.cancel(); + remoteMessaging.dispose(); + } + } + + void _onMessage(RemoteMessage message) { + _messagesController.add(message); + } + + void _onToken(String? token) { + _tokenController.add(token); + } + + @override + void dispose() { + for (var v in _remoteMessagings) { + v.dispose(); + } + _remoteMessagings.clear(); + super.dispose(); + } + + /// This function returns the first initial message that can be obtained from the list of managed [RemoteMessage]s. + /// If there were none, this returns null. + @override + Future getInitialMessage() { + // Only get the first message. + return Future.wait( + _remoteMessagings.map>( + (v) => v.getInitialMessage(), + )).then( + (v) => v.firstWhere( + (b) => b != null, + orElse: () => null, + ), + ); + } + + /// The stream of all [RemoteMessage]s managed by this class. + @override + Stream get messages => _messagesController.stream; + + /// The stream of all tokens from all [RemoteMessage]s managed by this class. + @override + Stream get tokens => _tokenController.stream; + + /// Obtains the first token from all [RemoteMessage]s managed by this class. + @override + Future getToken() async { + assert(() { + if (_remoteMessagings.length > 1) { + _logger.fine( + 'Multiple RemoteMessaging registered in ProxyRemoteMessaging. Only returning first one found.'); + } + + return true; + }()); + + if (_remoteMessagings.isNotEmpty) { + try { + return _remoteMessagings.first.getToken(); + // coverage:ignore-start + } catch (e, stackTrace) { + _logger.info('Failed to get token from plugin', e, stackTrace); + } + // coverage:ignore-end + } + + return null; + } + + /// This function registers the [RemoteMessaging] with the specified channel name [channel] in the list of [RemoteMessaging] managed by this class. + /// If there are multiple [RemoteMessaging], it returns true if the first one that matches the channel name [channel] is found. + @override + FutureOr listenChannel(String channel) { + return Future.wait( + _remoteMessagings + .map>((v) async => v.listenChannel(channel)), + ) + .then( + (v) => v.firstWhere( + (b) => b, + orElse: () => false, + ), + ) + .then((v) async { + await super.listenChannel(channel); + return v; + }); + } + + /// This function ignores messages with the specified channel name [channel] from the list managed by this class. + /// If there are multiple [RemoteMessaging], it executes the ignore operation on the first one that matches the channel name [channel]. If the ignore operation is successful, it returns true. + @override + FutureOr ignoreChannel(String channel) { + return Future.wait( + _remoteMessagings + .map>((v) async => v.ignoreChannel(channel)), + ) + .then( + (v) => v.firstWhere( + (b) => b, + orElse: () => false, + ), + ) + .then((v) async { + await super.ignoreChannel(channel); + return v; + }); + } +} + +/// This is a mock class for RemoteMessaging, used for testing purposes. +class MockRemoteMessaging extends RemoteMessaging { + final Future Function()? _getInitialMessage; + final Stream Function()? _messages; + final Stream Function()? _tokens; + final Future Function()? _getToken; + + /// Constructor for the MockRemoteMessaging class. + /// The arguments are functions that return the values you want to + /// return when you call the corresponding functions. + MockRemoteMessaging({ + Future Function()? getInitialMessage, + Stream Function()? messages, + Stream Function()? tokenStream, + Future Function()? getToken, + }) : _getInitialMessage = getInitialMessage, + _messages = messages, + _tokens = tokenStream, + _getToken = getToken; + + @override + Future getInitialMessage() { + if (_getInitialMessage != null) { + return _getInitialMessage!(); + } + + return Future.value(null); + } + + @override + Stream get messages { + if (_messages != null) { + return _messages!(); + } + + return const Stream.empty(); + } + + @override + Stream get tokens { + if (_tokens != null) { + return _tokens!(); + } + + return const Stream.empty(); + } + + @override + Future getToken() { + if (_getToken != null) { + return _getToken!(); + } + + return Future.value(null); + } +} diff --git a/packages/patapata_core/lib/src/sequential_work_queue.dart b/packages/patapata_core/lib/src/sequential_work_queue.dart new file mode 100644 index 0000000..1e3709f --- /dev/null +++ b/packages/patapata_core/lib/src/sequential_work_queue.dart @@ -0,0 +1,472 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; +import 'dart:collection'; + +final class _SequentialWorkQueueItem + extends LinkedListEntry<_SequentialWorkQueueItem> { + final Completer completer; + final FutureOr Function() work; + final FutureOr Function()? onCancel; + Completer? cancellingCompleter; + bool cancelled = false; + final asyncQueue = Queue>(); + + _SequentialWorkQueueItem(this.completer, this.work, this.onCancel); +} + +/// A utility class that will process work giving to it +/// in order, without failure. +/// +/// It is guaranteed under a standard Zone that all work +/// will be processed, whether previous works fails or not. +class SequentialWorkQueue { + final _queue = LinkedList<_SequentialWorkQueueItem>(); + + /// Checks whether the current code is running inside this [SequentialWorkQueue] work callback via [add]. + bool get insideSequentialWorkQueue => + Zone.current[#sequentialWorkQueue] == hashCode; + + /// Add [work] to this [SequentialWorkQueue]. + /// It will be executed when all previous [work] + /// registered to the [SequentialWorkQueue] as completed. + /// + /// If the [work] added here throws, the Future returned by + /// this function will error. However future [work] will + /// continue to be processed correctly. + /// + /// [work] is considered complete when the function itself returns, when + /// all [Future]s returned by [work] have completed, and if any asynchronous work + /// scheduled with [Future.microtask], [Future] or [Timer] inside [work] has completed. + /// Similar to how a main function is considered complete when all asynchronous work + /// scheduled within it has completed. + /// + /// If you wish to disable this behavior, you can run the [work] in a separate [Zone] + /// with `zoneValues: {#sequentialWorkQueueNoWaitForAsync: true}` set. + /// + /// You can optionally pass [onCancel] that will be called + /// if something attempts to clear this [SequentialWorkQueue] with methods like [clear]. + /// If [onCancel] returns true, this [work] with either stop executing or not execute all together, and [add] will return null. + /// If [onCancel] returns false, this [work] does not support cancelling, and will continue to execute until completion. + /// If [onCancel] is not passed, the default is the same as if onCancel returned true. + /// + /// When [onCancel] returns true, work that is current running in a separate [Zone] + /// will be allowed to complete. Code directly after the [Zone] execution will not be executed. + /// + /// Example: + /// ```dart + /// final tQueue = SequentialWorkQueue(); + /// + /// await tQueue.add(() async { + /// print('1'); + /// await Future.delayed(Duration(seconds: 1)); + /// print('2'); + /// }); + /// + /// tQueue.add(() async { + /// print('3'); + /// await Future.delayed(Duration(seconds: 1)); + /// print('4'); + /// }); + /// + /// // Just for demo purposees, wait for one tick to allow the print('3') line to execute. + /// // But then it will wait at the next await. + /// await Future.microtask(() => null); + /// + /// // Executing this here means the code beyond print('3') will not execute. + /// await tQueue.clear(); + /// + /// // Prints: 1, 2, 3 + /// ``` + Future add( + FutureOr Function() work, [ + FutureOr Function()? onCancel, + ]) async { + final tCompleter = Completer(); + final tQueueItem = _SequentialWorkQueueItem(tCompleter, work, onCancel); + _queue.add(tQueueItem); + + final tValueCompleter = Completer(); + + Future fFinish( + bool cancel, + T? value, [ + Object? error, + StackTrace? stackTrace, + ]) async { + if (!cancel) { + // This should only be called once. + + // Wait for all async work to complete. + while (tQueueItem.asyncQueue.isNotEmpty) { + await tQueueItem.asyncQueue.removeFirst(); + + if (tQueueItem.cancelled) { + // We got cancelled while waiting. + // Clean up a bit and just return. + // If we are cancelled, that means the cancel condition + // of the above code has already been met and we don't need + // to do anything else. + tQueueItem.asyncQueue.clear(); + + return; + } + } + } + + if (!tCompleter.isCompleted) { + tQueueItem.unlink(); + tCompleter.complete(); + } + + if (!tValueCompleter.isCompleted) { + if (error != null) { + tValueCompleter.completeError(error, stackTrace); + } else { + tValueCompleter.complete(value); + } + } + } + + try { + if (tQueueItem.previous != null) { + await tQueueItem.previous!.completer.future; + + if (tQueueItem.cancelled) { + fFinish(true, null); + return tValueCompleter.future; + } + } + + runZoned( + () { + try { + final tResult = work(); + + if (tResult != null) { + if (tResult is Future) { + tResult.then( + (value) { + fFinish(false, value); + }, + onError: (error, stackTrace) { + fFinish(false, null, error, stackTrace); + }, + ); + } else { + fFinish(false, tResult); + } + } else { + fFinish(false, null); + } + } catch (error, stackTrace) { + fFinish(false, null, error, stackTrace); + } + }, + zoneValues: { + #sequentialWorkQueue: hashCode, + #sequentialWorkQueueItem: tQueueItem.hashCode, + }, + zoneSpecification: ZoneSpecification( + fork: (self, parent, zone, specification, zoneValues) { + return parent.fork( + zone, + specification, + { + ...?zoneValues, + // Keep this so we know if we are inside this queue. + #sequentialWorkQueue: hashCode, + // Reset this so we don't cancel code in this new Zone. + #sequentialWorkQueueItem: 0, + }, + ); + }, + scheduleMicrotask: (self, parent, zone, f) { + late Completer tAsyncCompleter; + + void fCompleteAsync() { + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter.complete(); + }); + } + + if (zone[#sequentialWorkQueueItem] != tQueueItem.hashCode) { + var tF = f; + + if (zone[#sequentialWorkQueueNoWaitForAsync] != true) { + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter = Completer(); + tQueueItem.asyncQueue.add(tAsyncCompleter.future); + }); + + tF = () { + fCompleteAsync(); + f(); + }; + } + + return parent.scheduleMicrotask(zone, tF); + } + + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter = Completer(); + tQueueItem.asyncQueue.add(tAsyncCompleter.future); + }); + + parent.scheduleMicrotask(zone, () { + if (!tQueueItem.cancelled) { + if (tQueueItem.cancellingCompleter != null) { + tQueueItem.cancellingCompleter!.future.then((cancelled) { + fCompleteAsync(); + + if (!cancelled) { + f(); + } else { + fFinish(true, null); + } + }); + } else { + fCompleteAsync(); + f(); + } + } else { + fCompleteAsync(); + fFinish(true, null); + } + }); + }, + createTimer: (self, parent, zone, duration, f) { + late Completer tAsyncCompleter; + + void fCompleteAsync() { + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter.complete(); + }); + } + + if (zone[#sequentialWorkQueueItem] != tQueueItem.hashCode) { + var tF = f; + + if (zone[#sequentialWorkQueueNoWaitForAsync] != true) { + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter = Completer(); + tQueueItem.asyncQueue.add(tAsyncCompleter.future); + }); + + tF = () { + fCompleteAsync(); + f(); + }; + } + + return parent.createTimer(zone, duration, tF); + } + + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter = Completer(); + tQueueItem.asyncQueue.add(tAsyncCompleter.future); + }); + + return parent.createTimer(zone, duration, () { + if (!tQueueItem.cancelled) { + if (tQueueItem.cancellingCompleter != null) { + tQueueItem.cancellingCompleter!.future.then((cancelled) { + fCompleteAsync(); + + if (!cancelled) { + f(); + } else { + fFinish(true, null); + } + }); + } else { + fCompleteAsync(); + f(); + } + } else { + fCompleteAsync(); + fFinish(true, null); + } + }); + }, + createPeriodicTimer: (self, parent, zone, period, f) { + late Completer tAsyncCompleter; + + void fCompleteAsync() { + if (!tAsyncCompleter.isCompleted) { + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter.complete(); + }); + } + } + + if (zone[#sequentialWorkQueueItem] != tQueueItem.hashCode) { + var tF = f; + + if (zone[#sequentialWorkQueueNoWaitForAsync] != true) { + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter = Completer(); + tQueueItem.asyncQueue.add(tAsyncCompleter.future); + }); + + tF = (Timer timer) { + fCompleteAsync(); + f(timer); + }; + } + + return parent.createPeriodicTimer(zone, period, tF); + } + + parent.run(Zone.current.parent ?? zone, () { + tAsyncCompleter = Completer(); + tQueueItem.asyncQueue.add(tAsyncCompleter.future); + }); + + late final _PeriodicTimer tTimer; + + final tBackingTimer = + parent.createPeriodicTimer(zone, period, (timer) { + if (!tQueueItem.cancelled) { + if (tQueueItem.cancellingCompleter != null) { + tQueueItem.cancellingCompleter!.future.then((cancelled) { + if (!cancelled) { + f(tTimer); + } else { + tTimer.cancelWithoutCallback(); + fCompleteAsync(); + fFinish(true, null); + } + }); + } else { + f(tTimer); + } + } else { + tTimer.cancelWithoutCallback(); + fCompleteAsync(); + fFinish(true, null); + } + }); + + tTimer = _PeriodicTimer( + tBackingTimer, + // This callback happens when the timer is cancelled. + () { + if (!tAsyncCompleter.isCompleted) { + // Notify that this timer has been cancelled. + // This will let the future for [add] to complete. + fCompleteAsync(); + } + }, + ); + + return tTimer; + }, + ), + ); + } catch (error, stackTrace) { + // If we get here, something went wrong. + // Can't think of a possible realistic code path + // that would cause this to happen, so we will ignore + // this line for coverage. + // coverage:ignore-start + fFinish(false, null, error, stackTrace); + // coverage:ignore-end + } + + return tValueCompleter.future; + } + + /// Clear the queue. + /// This is attempt to cancel all work pending and running. + /// It will return when all work has truly completed or cancelled. + /// + /// Warning. Never call this inside [SequentialWorkQueue.add.work]. + /// An assert will be thrown, or in release mode, ignored. + Future clear() async { + assert(Zone.current[#sequentialWorkQueueItem] == null, + 'Can not clear() a SequentialWorkQueue inside a work callback.'); + + final tQueue = _queue + .where((element) => element.cancellingCompleter == null) + .toList(growable: false); + + for (var i in tQueue) { + i.cancellingCompleter = Completer(); + } + + final tWorkToWaitFor = >[]; + + for (var i in tQueue) { + final tCompleter = i.cancellingCompleter!; + tWorkToWaitFor.add(i.completer.future); + + if (i.onCancel != null) { + try { + final tCancelResult = i.onCancel!(); + + if (tCancelResult == true) { + i.cancelled = true; + tCompleter.complete(true); + } else if (tCancelResult == false) { + i.cancellingCompleter = null; + tCompleter.complete(false); + } else { + final tCancelFinalResult = await tCancelResult; + + if (tCancelFinalResult) { + i.cancelled = true; + tCompleter.complete(true); + } else { + i.cancellingCompleter = null; + tCompleter.complete(false); + } + } + } catch (error, stackTrace) { + // Continue processing the queue. + // We will send this off to the zone unhandled callback. + i.cancellingCompleter = null; + tCompleter.complete(false); + Zone.current.handleUncaughtError(error, stackTrace); + } + } else { + i.cancelled = true; + tCompleter.complete(true); + } + } + + await Future.wait(tWorkToWaitFor); + } + + /// Whether this queue is empty + bool get isEmpty => _queue.isEmpty; + + /// Whether this queue is not empty + bool get isNotEmpty => _queue.isNotEmpty; +} + +class _PeriodicTimer implements Timer { + final Timer backingTimer; + final void Function() onCancel; + + _PeriodicTimer(this.backingTimer, this.onCancel); + + @override + void cancel() { + backingTimer.cancel(); + onCancel(); + } + + void cancelWithoutCallback() { + backingTimer.cancel(); + } + + @override + bool get isActive => backingTimer.isActive; + + @override + int get tick => backingTimer.tick; +} diff --git a/packages/patapata_core/lib/src/startup.dart b/packages/patapata_core/lib/src/startup.dart new file mode 100644 index 0000000..4743f1a --- /dev/null +++ b/packages/patapata_core/lib/src/startup.dart @@ -0,0 +1,395 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/src/exception.dart'; + +final _logger = Logger('StartupSequence'); + +/// A factory for states used in the [StartupSequence] system. +class StartupStateFactory { + /// Creates a [StartupState]. + /// [startupSequence] is the [StartupSequence] that generated this state. + final T Function(StartupSequence startupSequence) create; + final List> _transitions; + + const StartupStateFactory( + this.create, + List> transitions, + ) : _transitions = transitions; + + LogicStateFactory _toLogicStateFactory(StartupSequence startupSequence) { + return LogicStateFactory( + () => create(startupSequence)..onComplete.ignore(), + _transitions, + ); + } +} + +typedef StartupPageCompleter = void Function(Object? result); + +/// [LogicState] executed in [StartupSequence]. +abstract class StartupState extends LogicState { + StartupState(this.startupSequence); + + /// The [StartupSequence] that executed this state. + final StartupSequence startupSequence; + + Completer? _navigateCompleter; + + /// Navigate to page. + /// + /// When using [StandardAppPlugin], navigation to the page defined in + /// [StandardMaterialApp.pages] or [StandardCupertinoApp.pages] + /// can be achieved by passing the page's [Type]. + /// If you want custom navigation, implement a [Plugin] that overrides + /// [StartupNavigatorMixin.startupNavigateToPage]. + /// + /// Please pass [completer] to every page navigated to using this method. + /// When using [StandardAppPlugin], this would be [StandardPage.pageData]. + /// + /// By calling [completer] within the page, the internal processes are executed + /// and the [Future] of this method returns true. + /// If another state switches without calling [completer], + /// the [Future] returns false. + Future navigateToPage( + Object page, StartupPageCompleter completer) async { + final tPlugin = startupSequence._startupNavigator; + assert(tPlugin != null); + + await startupSequence.waitForSplash(); + + if (!this()) { + throw LogicStateNotCurrent(this); + } + assert( + _navigateCompleter == null || _navigateCompleter?.isCompleted == true); + final tCompleter = Completer(); + _navigateCompleter = tCompleter; + + backAllowed = true; + tPlugin?.startupNavigateToPage(page, (result) { + if (tCompleter.isCompleted) { + return; + } + + tCompleter.complete(true); + completer(result); + }); + + return tCompleter.future; + } + + @override + @mustCallSuper + void dispose() { + if (_navigateCompleter?.isCompleted == false) { + _navigateCompleter!.complete(false); + } + super.dispose(); + } + + @override + @mustCallSuper + void init(Object? data) { + super.init(data); + + Timer.run(() { + process(data).then((_) { + if (this()) { + complete(); + } + }).catchError((e, stackTrace) { + return startupSequence.waitForSplash().whenComplete(() async { + if (this()) { + completeError(e, stackTrace); + } + }); + }); + }); + } + + /// The process to be executed in the state. + /// When the Future of this method completes, it transitions to the next state. + /// + /// If defined in [LogicStateTransition], you can transition to another state using [to]. + /// In that case, this method does nothing even after completion. + Future process(Object? data); +} + +/// Manages the series of processes from app launch to the display of +/// the initial screen using [LogicStateMachine]. +/// When executing [StartupSequence.resetMachine], it starts execution from +/// the first state provided in the [StartupStateFactory] list. +/// +/// When all states succeed, [StartupSequence] calls [StartupNavigatorMixin.startupProcessInitialRoute]. +/// When using [StandardAppPlugin], this is synonymous with calling +/// [StandardRouterDelegate.processInitialRoute]. In other words, +/// If a deep link was used to start the app, that deep link's page will be displayed. +/// If no deep link was used, the route with a link of nothing (`r''`) will be navigated to. +/// +/// If the app is rendering widgets using the [StandardAppPlugin] system, +/// [StartupSequence.resetMachine] is automatically executed only once when the app is launched. +/// +/// [startupStateFactories] specifies the states to be executed sequentially. +/// This is the same as [LogicState] in [LogicStateMachine]. +/// +/// [waitSplashScreenDuration] specifies the waiting time for the splash screen. +/// [StartupState.navigateToPage] or [StartupNavigatorMixin.startupProcessInitialRoute] +/// will be executed after waiting for this duration to elapse. +/// Also, [removeNativeSplashScreen] is called once this time has passed. +/// (default is 1 second) +class StartupSequence { + StartupSequence({ + required List startupStateFactories, + Duration? waitSplashScreenDuration, + this.onSuccess, + this.onError, + }) : _startupStateFactories = startupStateFactories, + _waitSplashScreenDuration = + waitSplashScreenDuration ?? const Duration(milliseconds: 1000); + + LogicStateMachine? _machine; + final List _startupStateFactories; + + /// Called when all sequence processing is completed. + final void Function()? onSuccess; + + /// Called when an error occurs during the sequence. + /// If null, it will call [Logger.severe] instead. + final void Function(Object error, StackTrace? stackTrace)? onError; + + /// The details of the error that occurred during execution. + LogicStateError? get error => _error; + LogicStateError? _error; + + /// Whether the sequence has completed, successfully or due to an error. + bool get complete => _startupCompleted; + Completer? _startupCompleter; + bool _startupCompleted = false; + + final Duration _waitSplashScreenDuration; + Timer? _waitSplashScreenTimer; + Completer? _splashScreenCompleter; + bool _splashFinished = false; + + /// Whether the time specified by [waitSplashScreenDuration] has elapsed. + bool get splashFinished => _splashFinished; + + StartupNavigatorMixin? get _startupNavigator => + getApp().getPluginsOfType().reversed.firstOrNull; + + List _createLogicStateFactories() { + return [ + for (final factory in _startupStateFactories) + factory._toLogicStateFactory(this) + ]; + } + + /// Starts the sequence processing. + /// If called again, it restarts from the beginning. + /// + /// When using [StandardAppPlugin], upon restart, + /// navigation will be directed to the first page in + /// [StandardMaterialApp.pages] or [StandardCupertinoApp.pages]. + /// To customize this behavior, implement a [Plugin] that overrides + /// [StartupNavigatorMixin.startupOnReset]. + void resetMachine() { + final tIsFirstRun = !(_machine != null || complete); + if (_waitSplashScreenTimer?.isActive == true) { + _waitSplashScreenTimer?.cancel(); + } + + _waitSplashScreenTimer = Timer(_waitSplashScreenDuration, () { + getApp().removeNativeSplashScreen().whenComplete(() { + _splashFinished = true; + _splashScreenCompleter?.complete(); + _waitSplashScreenTimer = null; + }); + }); + + final tPreMachine = _machine; + _machine?.removeListener(_onUpdate); + _error = null; + _splashFinished = false; + _startupCompleted = false; + _machine = LogicStateMachine(_createLogicStateFactories()) + ..addListener(_onUpdate); + + if (!tIsFirstRun) { + if (tPreMachine?.complete == false) { + tPreMachine?.current.completeError(const ResetStartupSequence()); + } + _startupNavigator?.startupOnReset(); + } + } + + /// Waits until the time specified by [waitSplashScreenDuration] has elapsed. + Future waitForSplash() { + if (splashFinished) { + return SynchronousFuture(null); + } + if (_splashScreenCompleter == null || + _splashScreenCompleter?.isCompleted == true) { + _splashScreenCompleter = Completer(); + } + return _splashScreenCompleter!.future; + } + + /// Waits until the process is completed. + Future waitForComplete() { + if (complete) { + if (error != null) { + if (error?.stackTrace != null) { + throw Error.throwWithStackTrace(error!.error, error!.stackTrace!); + } + throw error!.error; + } + return SynchronousFuture(null); + } + if (_startupCompleter == null || _startupCompleter?.isCompleted == true) { + _startupCompleter = Completer(); + } + return _startupCompleter!.future; + } + + void _onUpdate() { + _logger.info(_machine!); + + final tMachine = _machine; + + if (tMachine != null && tMachine.complete) { + tMachine.removeListener(_onUpdate); + + waitForSplash().whenComplete(() { + if (tMachine.hashCode != _machine?.hashCode) { + return; + } + final tError = tMachine.error; + _error = tError; + _machine = null; + _startupCompleted = true; + if (tError != null) { + _startupCompleter?.completeError(tError.error, tError.stackTrace); + + if (onError != null) { + onError?.call(tError.error, tError.stackTrace); + } else { + _logger.severe( + tError.error.toString(), tError.error, tError.stackTrace); + } + } else { + _startupCompleter?.complete(); + + _startupNavigator?.startupProcessInitialRoute(); + onSuccess?.call(); + } + }); + } + } +} + +/// Implements page navigation during [StartupSequence] processing. +/// If you are performing page navigation outside of the [StandardAppPlugin] system, +/// implement a [Plugin] that uses this with a mixin. +mixin StartupNavigatorMixin { + /// Called from [StartupState.navigateToPage]. Implements page navigation. + void startupNavigateToPage(Object page, StartupPageCompleter completer) => + () {}; // coverage:ignore-line + + /// Called when all the processes of [StartupSequence] have successfully completed. + /// Implements the navigation process to the application home. + void startupProcessInitialRoute() => () {}; + + /// Called when [StartupSequence.resetMachine] is invoked during the processing of [StartupSequence]. + void startupOnReset() => () {}; +} + +class StartupNavigatorObserver extends NavigatorObserver { + final StartupSequence _startupSequence; + final Map _routeHashStateMap = {}; + + StartupNavigatorObserver({ + required StartupSequence startupSequence, + }) : _startupSequence = startupSequence; + + @override + void didPush(Route route, Route? previousRoute) { + if (_startupSequence.complete || _startupSequence._machine == null) { + if (_routeHashStateMap.isNotEmpty) { + _routeHashStateMap.clear(); + } + return; + } + + if (route.isActive && route.isCurrent) { + _routeHashStateMap[route] = + _startupSequence._machine!.current.runtimeType; + } + } + + @override + void didPop(Route route, Route? previousRoute) { + if (_startupSequence.complete || _startupSequence._machine == null) { + return; + } + if (previousRoute == null) { + return; + } + + if (previousRoute.isActive && previousRoute.isCurrent) { + final tBackState = _routeHashStateMap[previousRoute]; + if (tBackState != null && + tBackState != _startupSequence._machine?.current.runtimeType) { + _startupSequence._machine!.current.backByType(tBackState); + } + } + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (_startupSequence.complete || _startupSequence._machine == null) { + return; + } + + if (previousRoute == null) { + return; + } + + if (previousRoute.isActive && previousRoute.isCurrent) { + final tBackState = _routeHashStateMap[previousRoute]; + if (tBackState != null && + tBackState != _startupSequence._machine?.current.runtimeType) { + _startupSequence._machine!.current.backByType(tBackState); + } + } + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + if (_startupSequence.complete || _startupSequence._machine == null) { + return; + } + if (newRoute == null) { + return; + } + + if (newRoute.isActive && newRoute.isCurrent) { + _routeHashStateMap[newRoute] = + _startupSequence._machine!.current.runtimeType; + } + } +} + +/// Thrown when [StartupSequence.resetMachine] is called while [StartupSequence] is already running. +class ResetStartupSequence extends PatapataCoreException { + const ResetStartupSequence() : super(code: PatapataCoreExceptionCode.PPE301); +} diff --git a/packages/patapata_core/lib/src/user.dart b/packages/patapata_core/lib/src/user.dart new file mode 100644 index 0000000..69b75de --- /dev/null +++ b/packages/patapata_core/lib/src/user.dart @@ -0,0 +1,497 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; +import 'dart:collection'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'app.dart'; +import 'provider_model.dart'; + +final _logger = Logger('patapata.User'); + +class _CompareMap extends MapBase { + final _store = HashMap(); + + _CompareMap([Map store = const {}]) { + _store.addAll(store); + } + + @override + Object? operator [](Object? key) => _store[key]; + + @override + void operator []=(String key, Object? value) { + _store[key] = value; + } + + // coverage:ignore-start + @override + void clear() { + _store.clear(); + } + // coverage:ignore-end + + @override + Iterable get keys => _store.keys; + + // coverage:ignore-start + @override + Object? remove(Object? key) { + _store.remove(key); + return null; + } + // coverage:ignore-end + + @override + operator ==(Object other) => other is Map + ? mapEquals(_store, other is _CompareMap ? other._store : other) + : false; + + // coverage:ignore-start + @override + int get hashCode => const MapEquality().hash(_store); + // coverage:ignore-end +} + +/// User data while attempting to change the [User]. +class UserChangeData { + /// The user id. Corresponds to [User.id]. + /// + /// By using [getIdFor], you can obtain values overridden for each [Type]. + String? id; + + /// The user properties. Corresponds to [User.properties]. + /// + /// By using [getPropertiesFor], you can obtain values overridden for each [Type]. + final Map properties; + + /// The user arbitrary data. Corresponds to [User.getData]. + final Map data; + + /// The user id that has been overridden for each [Type]. + final Map idOverrides; + + /// The user properties that have been overridden for each [Type]. + final Map> propertiesOverrides; + + UserChangeData({ + required this.id, + required this.properties, + required this.data, + required this.idOverrides, + required this.propertiesOverrides, + }); + + /// Retrieves the [id] that has been overridden for each [Type]. + String? getIdFor() => idOverrides.containsKey(T) ? idOverrides[T] : id; + + /// Retrieves the [properties] that have been overridden for each [Type]. + Map getPropertiesFor() => + propertiesOverrides.containsKey(T) + ? (Map.from(properties)..addAll(propertiesOverrides[T]!)) + : Map.from(properties); + + /// Sets all values of properties to null. + void removeAllProperties() { + final tOldProperties = { + for (var i in properties.keys) i: null, + }; + + properties + ..clear() + ..addAll(_CompareMap()..addAll(tOldProperties)); + } +} + +/// User information of the application. +/// +/// This class is automatically created during application initialization +/// and can be accessed from [App.user] or [context.read] or [context.watch]. +class User extends ProviderModel { + /// The [App] that was passed in the constructor. + final App app; + + User({ + required this.app, + }); + + final _key = ProviderLockKey('patapata.UserKey'); + + /// The [ProviderLockKey] used for updating user data. + @protected + ProviderLockKey get key => _key; + + late final ProviderModelVariable _id = createVariable(null); + final Map _idOverrides = {}; + + /// The user id. + /// + /// By using [getIdFor], you can obtain values overridden for each [Type]. + /// + /// The value obtained may not be the latest. + String? get id => _id.unsafeValue; + + /// Retrieves the [id] that has been overridden for each [Type]. + /// + /// The value obtained may not be the latest. + String? getIdFor() => _idOverrides.containsKey(T) ? _idOverrides[T] : id; + + late final ProviderModelVariable<_CompareMap> _properties = + createVariable<_CompareMap>(_CompareMap()); + final Map _propertiesOverrides = {}; + + /// The user properties. + /// + /// This value can be shared outside of the application (such as in external packages). + /// (e.g., Analytics or Sentry) + /// + /// By using [getPropertiesFor], you can obtain values overridden for each [Type]. + /// + /// The value obtained may not be the latest. + Map get properties => Map.from(_properties.unsafeValue); + + /// Retrieves the [properties] that have been overridden for each [Type]. + /// + /// The value obtained may not be the latest. + Map getPropertiesFor() => + _propertiesOverrides.containsKey(T) + ? (properties..addAll(_propertiesOverrides[T]!)) + : properties; + + late final ProviderModelVariable<_CompareMap> _data = + createVariable<_CompareMap>(_CompareMap()); + + final List Function(User user, UserChangeData changes)> + _synchronousChangeListeners = []; + + /// Adds a listener to monitor updates to user data. + /// + /// When user-related data is updating, the [callback] is invoked, + /// and the changed value is set to [changes]. + /// The content of [changes] can be modified within the [callback]. + /// + /// Once all the listener's [callback]s have successfully completed, + /// the final values in [changes] are committed. + /// If any listener returns an error, the changes are discarded. + void addSynchronousChangeListener( + FutureOr Function(User user, UserChangeData changes) + callback) => + _synchronousChangeListeners.add(callback); + + /// Removes the listener that was added using [addSynchronousChangeListener]. + void removeSynchronousChangeListener( + FutureOr Function(User user, UserChangeData changes) + callback) => + _synchronousChangeListeners.remove(callback); + + /// All values of [User]. + /// + /// `[id, properties, data]` + List get variables => [ + _id, + _properties, + _data, + ]; + + /// Sets arbitrary data. + /// + /// This value can be used for data exchange within the application and + /// is not intended to be sent to external packages. + Future setData( + String key, + T value, + ) async { + await lock((batch) { + _logger.fine('setData:$key=$value'); + final tNewData = _CompareMap(batch.get(_data)); + tNewData[key] = value; + batch.set(_data, tNewData); + batch.commit(); + }, lockKey: _key); + } + + /// Retrieves arbitrary data. + /// + /// This value can be used for data exchange within the application and + /// is not intended to be sent to external packages. + Future getData(String key) async { + final tData = await _data.getValue(); + return tData[key] as T?; + } + + /// Synchronous process of [getData] + /// + /// The value obtained may not be the latest. + T? getDataSync(String key) { + return _data.unsafeValue[key] as T?; + } + + /// Removes the data of [key] that was set using [setData] or similar functions. + Future removeData(String key) async { + await lock((batch) { + _logger.fine('removeData:$key'); + final tData = batch.get(_data); + tData.remove(key); + batch.set(_data, _CompareMap(tData)); + batch.commit(); + }, lockKey: _key); + } + + /// Removes all the data that was set using [setData] and similar functions. + Future removeAllData() async { + await lock((batch) { + _logger.fine('removeAllData'); + batch.set(_data, _CompareMap()); + batch.commit(); + }, lockKey: _key); + } + + /// Retrieves the value specified by [key] from [properties]. + /// + /// [defaultValue] will be returned if [key] does not exist. + /// + /// The value obtained may not be the latest. + T? getProperty(String key, [T? defaultValue]) => + _properties.unsafeValue.containsKey(key) + ? _properties.unsafeValue[key] as T? + : defaultValue; + + Future _runSynchronousChangeListeners(ProviderModelBatch lock) async { + final tChangeData = UserChangeData( + id: lock.get(_id), + properties: Map.from(lock.get(_properties)), + data: Map.from(lock.get(_data)), + idOverrides: Map.from(_idOverrides), + propertiesOverrides: Map.from(_propertiesOverrides), + ); + + final tSynchronousChangeListeners = + _synchronousChangeListeners.toList(growable: false); + final tPossibleExternalChanges = tSynchronousChangeListeners.isNotEmpty; + + if (tPossibleExternalChanges) { + await lock.blockOverride(() async { + for (var i in tSynchronousChangeListeners) { + await i(this, tChangeData); + } + }); + + if (_id.unsafeValue != tChangeData.id) { + _logger + .info('Synchronous change listener changed id: ${tChangeData.id}'); + lock.set(_id, tChangeData.id); + } + + if (_properties.unsafeValue != tChangeData.properties) { + _logger.info( + 'Synchronous change listener changed properties: ${tChangeData.properties}'); + lock.set<_CompareMap>(_properties, _CompareMap(tChangeData.properties)); + } + + if (_data.unsafeValue != tChangeData.data) { + _logger.info( + 'Synchronous change listener changed data: ${tChangeData.data}'); + lock.set<_CompareMap>(_data, _CompareMap(tChangeData.data)); + } + + _idOverrides + ..clear() + ..addAll(tChangeData.idOverrides); + _propertiesOverrides + ..clear() + ..addAll(tChangeData.propertiesOverrides + .map((key, value) => MapEntry(key, _CompareMap()..addAll(value)))); + } + } + + /// Sets a property. + /// + /// This value can be shared outside of the application (such as in external packages). + /// (e.g., Analytics or Sentry) + /// + /// With [overrideProperties], you can override property values in any [Type]. + /// Overridden values can be retrieved using [getPropertiesFor]. + Future setProperties( + Map properties, { + Map>? overrideProperties, + }) async { + await lock((batch) async { + _logger.fine('setProperties:$properties'); + + final tProperties = batch.get(_properties); + final tNewProperties = _CompareMap(tProperties); + tNewProperties.addAll(properties); + batch.set(_properties, tNewProperties); + + if (overrideProperties != null) { + for (var i in overrideProperties.entries) { + _propertiesOverrides[i.key] ??= _CompareMap(); + _propertiesOverrides[i.key]!.addAll(i.value); + } + } + + await _runSynchronousChangeListeners(batch); + + await app.remoteConfig.fetch(); + + batch.commit(); + }, lockKey: _key); + } + + /// Sets the user's id and various values. + /// + /// Executing this function will discard the current values of [User.properties]. + /// + /// With [overrideId], you can override the id value in any [Type]. + /// Overridden values can be retrieved using [getIdFor]. + /// + /// With [overrideProperties], you can override property values in any [Type]. + /// Overridden values can be retrieved using [getPropertiesFor]. + Future changeId( + String? id, { + Map? properties, + Map? data, + Map? overrideId, + Map>? overrideProperties, + }) async { + await lock((batch) async { + await changeIdWithBatch( + batch: batch, + id: id, + properties: properties, + data: data, + overrideId: overrideId, + overrideProperties: overrideProperties, + ); + + batch.commit(); + }, lockKey: _key); + } + + /// Sets the user's id and various values using the provided [ProviderModelBatch]. + @protected + Future changeIdWithBatch({ + required ProviderModelBatch batch, + required String? id, + Map? properties, + Map? data, + Map? overrideId, + Map>? overrideProperties, + }) async { + _logger.fine('changeId:$id, $properties, $data'); + + batch.set(_id, id); + + _idOverrides.clear(); + + if (overrideId != null) { + for (var i in overrideId.entries) { + _idOverrides[i.key] = i.value; + } + } + + // notify listeners to remove old properties. + final tOldProperties = { + for (var i in batch.get(_properties).keys) i: null, + }; + + final tProperties = _CompareMap(tOldProperties); + + if (properties != null) { + tProperties.addAll(properties); + } + + batch.set(_properties, tProperties); + + if (data != null) { + batch.set(_data, _CompareMap(data)); + } + + _propertiesOverrides.clear(); + + if (overrideProperties != null) { + for (var i in overrideProperties.entries) { + _propertiesOverrides[i.key] ??= _CompareMap(); + _propertiesOverrides[i.key]!.addAll(i.value); + } + } + + await _runSynchronousChangeListeners(batch); + + await app.remoteConfig.fetch(force: true); + } + + /// Sets the properties and arbitrary data. + /// + /// With [overrideProperties], you can override property values in any [Type]. + /// Overridden values can be retrieved using [getPropertiesFor]. + Future set({ + Map? properties, + Map? data, + Map>? overrideProperties, + }) async { + await lock((batch) async { + await setWithBatch( + batch: batch, + properties: properties, + data: data, + overrideProperties: overrideProperties, + ); + + batch.commit(); + }, lockKey: _key); + } + + /// Sets the properties and arbitrary data using the provided [ProviderModelBatch]. + @protected + Future setWithBatch({ + required ProviderModelBatch batch, + Map? properties, + Map? data, + Map>? overrideProperties, + }) async { + _logger.fine('set:$properties, $data'); + + if (properties != null) { + final tProperties = batch.get(_properties); + final tNewProperties = _CompareMap(tProperties); + tNewProperties.addAll(properties); + batch.set(_properties, tNewProperties); + } + + if (data != null) { + final tData = batch.get(_data); + final tNewData = _CompareMap(tData); + tNewData.addAll(data); + batch.set(_data, tNewData); + } + + if (overrideProperties != null) { + for (var i in overrideProperties.entries) { + _propertiesOverrides[i.key] ??= _CompareMap(); + _propertiesOverrides[i.key]!.addAll(i.value); + } + } + + await _runSynchronousChangeListeners(batch); + + try { + await app.remoteConfig.fetch( + force: batch.get(_id) != _id.unsafeValue, + ); + } catch (e, stackTrace) { + _logger.info('Failed to refresh RemoteConfig.', e, stackTrace); + } + } + + @override + operator ==(Object other) => other is User ? other.id == id : false; + + @override + int get hashCode => Object.hash(null, id.hashCode); +} diff --git a/packages/patapata_core/lib/src/util.dart b/packages/patapata_core/lib/src/util.dart new file mode 100644 index 0000000..7bb2c17 --- /dev/null +++ b/packages/patapata_core/lib/src/util.dart @@ -0,0 +1,289 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +/// A value that can be used as a 32 bit integer. +/// It's value is just `pata` in ascii hexidecimal. +const int kPataInHex = 0x70617461; + +/// Determines if [T] is a subclass of another [S] +bool typeIs() => [] is List; + +/// Set this to execute a callback every time [scheduleFunction] +/// is executed, after a frame has been scheduled, but before the +/// post frame callback is set. +/// Mostly used for testing purposes as a way to pump automatically. +@visibleForTesting +VoidCallback? onPostFrameCallback; + +/// Sets a mock method call handler for the given [MethodChannel] for testing purposes. +/// +/// The mock method call handler is a function that is called when a method call is made on the [MethodChannel]. +/// By default, this function does nothing. +/// However, in testing code, this function should be set to +/// [TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler] to allow +/// integration with the testing framework. +/// +/// If you use the [createApp] function defined in patapata_core_test_utils.dart this is done for you. +@visibleForTesting +void Function(MethodChannel, Future? Function(MethodCall)?) + testSetMockMethodCallHandler = (channel, handler) { + // Do nothing by default. +}; + +/// Sets a mock stream handler for the given [EventChannel] for testing purposes. +/// +/// The mock stream handler is a function that is called when an event is sent on the [EventChannel]. +/// By default, this function does nothing. +/// However, in testing code, this function should be set to +/// [TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockStreamHandler] to allow +/// integration with the testing framework. +/// +/// If you use the [createApp] function defined in patapata_core_test_utils.dart, this is done for you. +// coverage:ignore-start +@visibleForTesting +void Function(EventChannel, TestMockStreamHandler?) testSetMockStreamHandler = + (channel, handler) { + // Do nothing by default. +}; +// coverage:ignore-end + +/// [compute] doesn't work in a test environment or on the web. +/// This function runs compute on supported platforms but on +/// unsupported platforms runs a simple microtask via [scheduleMicrotask]. +FutureOr platformCompute( + ComputeCallback callback, + Q message, { + String? debugLabel, +}) { + if (kIsWeb || !Platform.environment.containsKey('FLUTTER_TEST')) { + // coverage:ignore-start + return compute( + callback, + message, + debugLabel: debugLabel, + ); + // coverage:ignore-end + } else { + final Completer tCompleter = Completer(); + + scheduleMicrotask(() async { + try { + final R tResult = await callback(message); + tCompleter.complete(tResult); + } catch (error, stackTrace) { + tCompleter.completeError(error, stackTrace); + } + }); + + return tCompleter.future; + } +} + +/// Executes [func] asynchronously. +/// If this application is active and rendering, it will use +/// [SchedulerBinding.addPostFrameCallback], ensuring that it will run +/// with a call to [SchedulerBinding.scheduleFrame]. +/// If the application is not active and rendering, ie, in the background, +/// it will simply add a 1ms timer and execute [func] after that time. +void scheduleFunction(VoidCallback func) { + if (SchedulerBinding.instance.framesEnabled == true) { + SchedulerBinding.instance.scheduleFrame(); + // This is here to allow test frameworks to call pump() correctly. + if (onPostFrameCallback != null) { + onPostFrameCallback!(); + } + + SchedulerBinding.instance.addPostFrameCallback((_) { + func(); + }); + + return; + } + // coverage:ignore-start + Timer(const Duration(milliseconds: 1), func); + // coverage:ignore-end +} + +extension DateDateTimeExtension on DateTime { + /// Dart's [DateTime.toIso8601String] function includes milliseconds and microseconds. + /// Many backend systems don't support this, and for the most part, time information + /// stored is fine if stored with second accuracy only. + /// + /// This function returns a [String] in ISO8601 format, in UTC, down to second accuracy. + String toUTCIso8601StringNoMSUS() { + if (!isUtc) { + return toUtc().toUTCIso8601StringNoMSUS(); + } + + String y = (year >= -9999 && year <= 9999) + ? '$year'.padLeft(4, '0') + : '$year'.padLeft(6, '0'); + String m = '$month'.padLeft(2, '0'); + String d = '$day'.padLeft(2, '0'); + String h = '$hour'.padLeft(2, '0'); + String min = '$minute'.padLeft(2, '0'); + String sec = '$second'.padLeft(2, '0'); + + return '$y-$m-${d}T$h:$min:${sec}Z'; + } +} + +const _kFakeNowLocalConfigKey = 'patapataFakeNow'; +DateTime? _now; +final _fakeTimeElapsedSince = Stopwatch(); + +/// Set a fake time that [now] will return. +/// Set to null to return to real time again. +/// +/// If [persist] is set to true, +/// it is persisted between restarts by saving the time to [LocalConfig]. +/// +/// If [elapse] is set to true, the time returned from [now] +/// will be [fakeNow] plus the elapsed time since this function +/// was executed. +/// This allows for syncing time to say, a remote server. +/// +/// Warning: +/// Using [elapse] set to true in combination with [persist] set to true +/// will result in only [fakeNow] being persisted, causing the value +/// loaded on app restart to be exactly [fakeNow] and not any time that +/// has elapsed after it was saved. +void setFakeNow( + DateTime? fakeNow, { + bool persist = true, + bool elapse = false, +}) { + _now = fakeNow; + + if (elapse) { + _fakeTimeElapsedSince + ..reset() + ..start(); + } else { + _fakeTimeElapsedSince.stop(); + } + + if (persist) { + if (fakeNow == null) { + getApp().localConfig.reset(_kFakeNowLocalConfigKey); + } else { + getApp().localConfig.setString( + _kFakeNowLocalConfigKey, fakeNow.toUTCIso8601StringNoMSUS()); + } + } + + _fakeNowStreamController.add(fakeNow); +} + +/// Load the saved fake time from [LocalConfig], previously set by [setFakeNow]. +void loadFakeNow() { + final tFakeNowString = + getApp().localConfig.getString(_kFakeNowLocalConfigKey); + + if (tFakeNowString.isEmpty) { + return; + } + + final tDateTime = DateTime.tryParse(tFakeNowString); + + if (tDateTime == null) { + return; + } + + _now = tDateTime; +} + +/// Whether is fake time is currently set or not via [setFakeNow]. +bool get fakeNowSet => _now != null; + +final _fakeNowStreamController = StreamController.broadcast(); + +/// A stream to listen to when fake now changes. +Stream get fakeNowStream => _fakeNowStreamController.stream; + +/// Get the current [DateTime]. +/// The value returned here can be overriden for +/// debug or testing purposes with [setFakeNow]. +DateTime get now => + (_fakeTimeElapsedSince.isRunning && _now != null + ? _now!.add(_fakeTimeElapsedSince.elapsed) + : _now) ?? + DateTime.now(); + +/// Typedef for the inline onCancel callback. +@visibleForTesting +typedef TestMockStreamHandlerOnCancelCallback = void Function( + Object? arguments); + +/// Typedef for the inline onListen callback. +@visibleForTesting +typedef TestMockStreamHandlerOnListenCallback = void Function( + Object? arguments, TestMockStreamHandlerEventSink events); + +/// Test class for testing stream handlers. +/// The app does not reference this class. +@visibleForTesting +abstract class TestMockStreamHandler { + /// Create a [TestMockStreamHandler]. + TestMockStreamHandler(); + + /// Create a new inline [TestMockStreamHandler] with the given [onListen] and + /// [onCancel] handlers. + factory TestMockStreamHandler.inline({ + required TestMockStreamHandlerOnListenCallback onListen, + TestMockStreamHandlerOnCancelCallback? onCancel, + }) => + _InlineMockStreamHandler(onListen: onListen, onCancel: onCancel); + + /// Handler for the listen event. + void onListen(Object? arguments, TestMockStreamHandlerEventSink events); + + /// Handler for the cancel event. + void onCancel(Object? arguments); +} + +class _InlineMockStreamHandler extends TestMockStreamHandler { + _InlineMockStreamHandler({ + required TestMockStreamHandlerOnListenCallback onListen, + TestMockStreamHandlerOnCancelCallback? onCancel, + }) : _onListenInline = onListen, + _onCancelInline = onCancel; + + final TestMockStreamHandlerOnListenCallback _onListenInline; + final TestMockStreamHandlerOnCancelCallback? _onCancelInline; + + @override + void onListen(Object? arguments, TestMockStreamHandlerEventSink events) => + _onListenInline(arguments, events); + + @override + void onCancel(Object? arguments) => _onCancelInline?.call(arguments); +} + +/// Class for events referenced in [TestMockStreamHandler.onListen]. +/// The app does not reference this class. +@visibleForTesting +abstract class TestMockStreamHandlerEventSink { + /// Send a success event. + void success(Object? event); + + /// Send an error event. + void error({ + required String code, + String? message, + Object? details, + }); + + /// Send an end of stream event. + void endOfStream(); +} diff --git a/packages/patapata_core/lib/src/widgets/platform_dialog.dart b/packages/patapata_core/lib/src/widgets/platform_dialog.dart new file mode 100644 index 0000000..bd1761b --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/platform_dialog.dart @@ -0,0 +1,183 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +// ignore_for_file: sort_child_properties_last + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; + +final _queue = SequentialWorkQueue(); + +/// The set of actions that are displayed at the bottom of the dialog. +/// +/// On iOS, [CupertinoDialogAction] is used, and on other platforms, [TextButton] is used. +class PlatformDialogAction { + /// The callback that is called when the button is tapped or otherwise + /// activated. + /// + /// This is called when the dialog is popped, and its return value becomes the + /// return value of [PlatformDialog.show]. + final T Function() result; + + /// The callback that is called when the button is tapped or otherwise + /// activated. + /// + /// This is called after the dialog has been popped. + final VoidCallback? action; + + /// Whether this action destroys an object. + /// + /// This value is effective on iOS only. + final bool isDestructive; + + /// Set to true if button is the default choice in the dialog. + /// + /// This value is effective on iOS only. + final bool isDefault; + + /// Text to display on the button. + /// + /// If [child] is provided, this value is ignored. + final String? text; + + /// Widget to display as a button. + /// + /// If not specified, it displays [text] using the [Text] widget. + final Widget? child; + + const PlatformDialogAction({ + required this.result, + this.action, + this.isDestructive = false, + this.isDefault = false, + this.text, + this.child, + }) : assert(text != null || child != null); +} + +/// By calling [show], a dialog styled according to each platform will be displayed. +/// This wraps Flutter's [showDialog] to match the style of each platform. +/// +/// On iOS, [CupertinoAlertDialog] is used, and on other platforms, [AlertDialog] is used. +class PlatformDialog { + /// Displays a dialog. + /// + /// When the dialog is popped, [PlatformDialogAction.result] is called and its + /// return value becomes the result of the Future. + static Future show({ + required BuildContext context, + required List> actions, + String? title, + String? message, + Widget? content, + bool barrierDismissible = true, + Color? barrierColor = Colors.black54, + String? barrierLabel, + bool useSafeArea = true, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, + Function()? onClose, + }) async { + assert(content != null || message != null); + + return _queue.add(() { + if (context.owner == null) { + return null; + } + + return showDialog( + context: context, + builder: (context) => _PlatformAlertDialog( + content: content ?? Text(message!), + actions: actions, + title: title, + ), + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useSafeArea: useSafeArea, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + anchorPoint: anchorPoint, + ); + }); + } +} + +class _PlatformAlertDialog extends StatelessWidget { + const _PlatformAlertDialog({ + Key? key, + required this.content, + required this.actions, + this.title, + }) : super(key: key); + + final String? title; + final Widget content; + final List> actions; + + @override + Widget build(BuildContext context) { + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + return CupertinoAlertDialog( + title: title != null ? Text(title!) : null, + // Add a Material so you can use Material library Widgets + // as a child. + content: Material( + child: SingleChildScrollView(child: content), + color: Colors.transparent, + ), + actions: actions.map((action) { + return CupertinoDialogAction( + isDefaultAction: action.isDefault, + isDestructiveAction: action.isDestructive, + child: action.child ?? + Text( + action.text!, + textAlign: TextAlign.center, + ), + onPressed: () { + Navigator.pop(context, action.result()); + action.action?.call(); + }, + ); + }).toList(), + ); + } + + final tTheme = Theme.of(context); + + return Theme( + data: ThemeData.from( + colorScheme: tTheme.colorScheme, + useMaterial3: tTheme.useMaterial3, + ), + child: AlertDialog( + title: title != null ? Text(title!) : null, + content: SingleChildScrollView(child: content), + actions: actions.map((action) { + return TextButton( + child: action.child ?? + Text( + action.text!, + textAlign: TextAlign.end, + ), + onPressed: () { + Navigator.pop(context, action.result()); + action.action?.call(); + }, + ); + }).toList(), + titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0), + contentPadding: EdgeInsets.fromLTRB(24, title != null ? 16 : 24, 24, 0), + actionsPadding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + ), + ); + } +} diff --git a/packages/patapata_core/lib/src/widgets/screen_layout.dart b/packages/patapata_core/lib/src/widgets/screen_layout.dart new file mode 100644 index 0000000..c42d821 --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/screen_layout.dart @@ -0,0 +1,562 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:math'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:provider/provider.dart'; + +/// Application screen layout settings. Used in [ScreenLayout.breakpoints]. +mixin ScreenLayoutEnvironment { + /// Map of names and their configuration values for [ScreenLayoutBreakpoints]. + Map get screenLayoutBreakpoints; +} + +/// A class representing breakpoints for screen layout. Breakpoints refer to the widths determined by the application's design. +/// You can set the breakpoints for portrait mode and landscape mode ([portraitStandardBreakpoint], [landscapeStandardBreakpoint]), +/// as well as width constraints for portrait mode and landscape mode ([portraitConstrainedWidth], [landscapeConstrainedWidth]). +/// Additionally, by setting [maxScale], you can limit the scale to prevent the RenderSize of child elements from exceeding it. +/// Pass these settings to [ScreenLayout.breakpoints]. +/// +/// example: +/// ```dart +/// ScreenLayout( +/// breakpoints: const ScreenLayoutBreakpoints( +/// portraitStandardBreakpoint: 375.0, +/// portraitConstrainedWidth: double.infinity, +/// landscapeStandardBreakpoint: 375.0, +/// landscapeConstrainedWidth: double.infinity, +/// maxScale: 1.2, +/// ), +/// child: HogehogeWidget(), +/// ), +/// ``` +class ScreenLayoutBreakpoints { + /// This function creates a breakpoint with the specified [name]. + /// You can specify the portrait breakpoint ([portraitStandardBreakpoint]) and its width constraint ([portraitConstrainedWidth]), + /// as well as the landscape breakpoint ([landscapeStandardBreakpoint]) and its width constraint ([landscapeConstrainedWidth]). + /// The [maxScale] is a value that limits the scale to ensure it does not exceed the RenderSize of the child element. + const ScreenLayoutBreakpoints({ + this.name, + required this.portraitStandardBreakpoint, + required this.portraitConstrainedWidth, + required this.landscapeStandardBreakpoint, + required this.landscapeConstrainedWidth, + required this.maxScale, + }); + + /// Name of the breakpoint + final String? name; + + /// Value of the breakpoint in portrait mode + final double portraitStandardBreakpoint; + + /// Value of the width constraint in portrait mode + final double portraitConstrainedWidth; + + /// Value of the breakpoint in landscape mode + final double landscapeStandardBreakpoint; + + /// Value of the width constraint in landscape mode + final double landscapeConstrainedWidth; + + /// Value to limit the scale to prevent the RenderSize of child elements from exceeding it + final double maxScale; + + /// Partially modifies the properties of the existing [ScreenLayoutBreakpoints]. + /// If a property is not specified, the existing value will be used. + ScreenLayoutBreakpoints copyWith({ + String? name, + double? portraitStandardBreakpoint, + double? portraitConstrainedWidth, + double? landscapeStandardBreakpoint, + double? landscapeConstrainedWidth, + double? maxScale, + }) { + return ScreenLayoutBreakpoints( + name: name ?? this.name, + portraitStandardBreakpoint: + portraitStandardBreakpoint ?? this.portraitStandardBreakpoint, + portraitConstrainedWidth: + portraitConstrainedWidth ?? this.portraitConstrainedWidth, + landscapeStandardBreakpoint: + landscapeStandardBreakpoint ?? this.landscapeStandardBreakpoint, + landscapeConstrainedWidth: + landscapeConstrainedWidth ?? this.landscapeConstrainedWidth, + maxScale: maxScale ?? this.maxScale, + ); + } + + @override + operator ==(Object other) => other is ScreenLayoutBreakpoints + ? name == other.name && + portraitStandardBreakpoint == other.portraitStandardBreakpoint && + portraitConstrainedWidth == other.portraitConstrainedWidth && + landscapeStandardBreakpoint == other.landscapeStandardBreakpoint && + landscapeConstrainedWidth == other.landscapeConstrainedWidth && + maxScale == other.maxScale + : false; + + @override + int get hashCode => Object.hash( + name, + portraitStandardBreakpoint, + portraitConstrainedWidth, + landscapeStandardBreakpoint, + landscapeConstrainedWidth, + maxScale, + ); +} + +/// A class that defines default screen layout breakpoints. +class ScreenLayoutDefaultBreakpoints { + /// A default breakpoint named 'normal'. + /// The breakpoint values for [ScreenLayoutBreakpoints.portraitStandardBreakpoint] and [ScreenLayoutBreakpoints.landscapeStandardBreakpoint] are `375.0`. + /// The constrained width values for [ScreenLayoutBreakpoints.portraitConstrainedWidth] and [ScreenLayoutBreakpoints.landscapeConstrainedWidth] are `482.0`. + /// The maximum scale value for [ScreenLayoutBreakpoints.maxScale] is `1.2`. + static const normal = ScreenLayoutBreakpoints( + name: 'normal', + portraitStandardBreakpoint: 375.0, + portraitConstrainedWidth: 16.0 + 450.0 + 16.0, + landscapeStandardBreakpoint: 375.0, + landscapeConstrainedWidth: 16.0 + 450.0 + 16.0, + maxScale: 1.2, + ); + + /// A default breakpoint named 'large'. + /// The breakpoint values for [ScreenLayoutBreakpoints.portraitStandardBreakpoint] and [ScreenLayoutBreakpoints.landscapeStandardBreakpoint] are `812.0`. + /// The constrained width values for [ScreenLayoutBreakpoints.portraitConstrainedWidth] and [ScreenLayoutBreakpoints.landscapeConstrainedWidth] are `1006.4`. + /// The maximum scale value for [ScreenLayoutBreakpoints.maxScale] is `1.2`. + static const large = ScreenLayoutBreakpoints( + name: 'large', + portraitStandardBreakpoint: 812.0, + portraitConstrainedWidth: 16.0 + 974.4 + 16.0, + landscapeStandardBreakpoint: 812.0, + landscapeConstrainedWidth: 16.0 + 974.4 + 16.0, + maxScale: 1.2, + ); + + /// The Map of default screen layout breakpoints. + static Map toMap() { + return { + 'normal': normal, + 'large': large, + }; + } +} + +/// A widget that will layout [child] at the given size in [breakpoints]. +/// [ScreenLayoutBreakpoints.portraitStandardBreakpoint] while in portrait mode, or +/// [ScreenLayoutBreakpoints.landscapeStandardBreakpoint] while in landscape mode. +/// After laying out at that size, [ScreenLayout] will scale the [child] so as to fix the current screen size, +/// up to a maximum scale ratio of [maxScale], or [ScreenLayoutBreakpoints.portraitConstrainedWidth] while in portrait mode, +/// or [ScreenLayoutBreakpoints.landscapeConstrainedWidth] while in landscape mode. +/// +/// This is useful for when you have a design where the base design is created with a given width, +/// and all UI elements are meant to be shown exactly as the design shows relative to the current device's screen size. +/// In other words, this widget will implement a non-responsive UI that automatically scales children up or down to match the current screen size. +/// It is usually a bad design practice to scale up to fullscreen on devices like a tablet or PC, and you can control the maximum things will scale by using the above-mentioned settings to avoid that and show a more reasonable UI design. +/// +/// example: +/// ```dart +/// ScreenLayout( +/// breakpoints: const ScreenLayoutBreakpoints( +/// portraitStandardBreakpoint: 375.0, +/// portraitConstrainedWidth: double.infinity, +/// landscapeStandardBreakpoint: 375.0, +/// landscapeConstrainedWidth: double.infinity, +/// maxScale: 1.2, +/// ), +/// child: HogehogeWidget(), +/// ), +/// ``` +class ScreenLayout extends SingleChildRenderObjectWidget { + /// Creates a [ScreenLayout] with the name [name] that adjusts the size of the widget specified in [child] based on the configuration in [breakpoints]. + const ScreenLayout({ + super.key, + super.child, + this.breakpoints, + this.name, + }); + + /// Creates a [ScreenLayout] with the name [name] that adjusts the size of the widget specified in [child]. + factory ScreenLayout.named({ + Key? key, + Widget? child, + required String name, + }) { + return ScreenLayout( + key: key, + name: name, + child: child, + ); + } + + /// Breakpoints for adjusting the size of the widget. + final ScreenLayoutBreakpoints? breakpoints; + + /// Name of the [ScreenLayout] widget. + final String? name; + + ScreenLayoutBreakpoints _getTargetBreakpoints(BuildContext context) { + ScreenLayoutBreakpoints? tBreakpoints = breakpoints; + if (tBreakpoints == null && name != null) { + final tEnvironment = context.read().environment; + if (tEnvironment is ScreenLayoutEnvironment) { + // It is assumed that defaults will be overridden. + final tBreakpointsMap = { + ...ScreenLayoutDefaultBreakpoints.toMap(), + ...tEnvironment.screenLayoutBreakpoints, + }; + tBreakpoints = tBreakpointsMap[name]; + } + } + + return tBreakpoints ?? ScreenLayoutDefaultBreakpoints.normal; + } + + @override + RenderObject createRenderObject(BuildContext context) { + return _ScreenLayoutRenderObject( + breakpoints: _getTargetBreakpoints(context), + orientation: MediaQuery.of(context).orientation, + ); + } + + @override + void updateRenderObject( + BuildContext context, + // ignore: library_private_types_in_public_api + covariant _ScreenLayoutRenderObject renderObject, + ) { + renderObject.breakpoints = _getTargetBreakpoints(context); + renderObject.orientation = MediaQuery.of(context).orientation; + } +} + +class _ScreenLayoutRenderObject extends RenderBox + with RenderObjectWithChildMixin { + Orientation _orientation; + set orientation(Orientation value) { + if (_orientation != value) { + _orientation = value; + markNeedsLayout(); + markNeedsPaint(); + } + } + + ScreenLayoutBreakpoints _breakpoints; + set breakpoints(ScreenLayoutBreakpoints value) { + if (_breakpoints != value) { + _breakpoints = value; + markNeedsLayout(); + markNeedsPaint(); + } + } + + late Matrix4 _transform; + late double _scale; + + _ScreenLayoutRenderObject({ + required ScreenLayoutBreakpoints breakpoints, + required Orientation orientation, + }) : _breakpoints = breakpoints, + _orientation = orientation; + + double get standardBreakpoint { + switch (_orientation) { + case Orientation.portrait: + return _breakpoints.portraitStandardBreakpoint; + case Orientation.landscape: + return _breakpoints.landscapeStandardBreakpoint; + } + } + + double get constrainedWidth { + switch (_orientation) { + case Orientation.portrait: + return _breakpoints.portraitConstrainedWidth; + case Orientation.landscape: + return _breakpoints.landscapeConstrainedWidth; + } + } + + @override + bool get alwaysNeedsCompositing => true; + + @override + bool get isRepaintBoundary => false; + + @override + void performLayout() { + final tChild = child; + final tWidth = constraints + .enforce(BoxConstraints.loose(Size(constrainedWidth, double.infinity))) + .constrainWidth(); + + if (tWidth.isInfinite || tWidth == 0) { + _transform = Matrix4.identity(); + _scale = 1.0; + } else { + _scale = min(_breakpoints.maxScale, tWidth / standardBreakpoint); + _transform = Matrix4.identity()..scale(_scale, _scale, _scale); + } + + if (tChild != null) { + tChild.layout( + BoxConstraints( + minWidth: constrainedWidth.isInfinite + ? constraints.minWidth / _scale + : standardBreakpoint, + maxWidth: constrainedWidth.isInfinite + ? constraints.maxWidth / _scale + : standardBreakpoint, + minHeight: constraints.minHeight / _scale, + maxHeight: constraints.maxHeight / _scale, + ), + parentUsesSize: true, + ); + } + + if (tChild is RenderBox) { + size = constraints.constrain(Size(tWidth, tChild.size.height * _scale)); + _transform.translate( + (tWidth - tChild.size.width * _scale) / 2, + (size.height - tChild.size.height * _scale) / 2, + ); + } else { + // coverage:ignore-start + size = constraints.constrain(Size(tWidth, 0)); + // coverage:ignore-end + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + return hitTestChildren(result, position: position); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (child == null) { + return false; + } + + return result.addWithPaintTransform( + transform: _transform, + position: position, + hitTest: (BoxHitTestResult result, Offset position) { + final tChild = child; + + if (tChild is RenderBox) { + return tChild.hitTest(result, position: position); + } + + return false; + }, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) { + return; + } + + Offset? tChildOffset = MatrixUtils.getAsTranslation(_transform); + + if (tChildOffset == null) { + // if the matrix is singular the children would be compressed to a line or + // single point, instead short-circuit and paint nothing. + final double tDet = _transform.determinant(); + // coverage:ignore-start + if (tDet == 0 || !tDet.isFinite) { + layer = null; + return; + } + // coverage:ignore-end + + layer = context.pushTransform( + needsCompositing, + offset, + _transform, + (context, offset) { + context.paintChild(child!, offset); + }, + oldLayer: layer is TransformLayer ? layer as TransformLayer? : null, + ); + } else { + context.paintChild(child!, offset + tChildOffset); + layer = null; + } + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.multiply(_transform); + } +} + +/// A widget that disables the screen layout of the child widget [child]. +class ScreenLayoutDisable extends SingleChildRenderObjectWidget { + /// Create a [ScreenLayoutDisable] + /// [child] is the widget for which the screen layout is to be disabled. + const ScreenLayoutDisable({ + super.key, + super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) => + _ScreenLayoutDisableRenderObject(); +} + +class _ScreenLayoutDisableRenderObject extends RenderBox + with RenderObjectWithChildMixin { + late Matrix4 _transform; + late double _scale; + + _ScreenLayoutDisableRenderObject(); + + @override + bool get alwaysNeedsCompositing => true; + + @override + bool get isRepaintBoundary => false; + + @override + void performLayout() { + double tParentScale = 1.0; + RenderObject? tNode = parent; + + while (tNode != null) { + if (tNode is _ScreenLayoutRenderObject) { + tParentScale /= tNode._scale; + break; + } + + tNode = tNode.parent; + } + + final tChild = child; + _scale = tParentScale; + _transform = Matrix4.identity()..scale(_scale, _scale, _scale); + + if (tChild != null) { + tChild.layout( + BoxConstraints( + minWidth: constraints.minWidth / _scale, + maxWidth: constraints.maxWidth / _scale, + minHeight: constraints.minHeight / _scale, + maxHeight: constraints.maxHeight / _scale, + ), + parentUsesSize: true, + ); + } + + if (tChild is RenderBox) { + size = constraints.constrain( + Size(tChild.size.width * _scale, tChild.size.height * _scale)); + _transform.translate( + (size.width - tChild.size.width * _scale) / 2, + (size.height - tChild.size.height * _scale) / 2, + ); + } else { + // coverage:ignore-start + size = const Size(0, 0); + // coverage:ignore-end + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + return hitTestChildren(result, position: position); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + if (child == null) { + return false; + } + + return result.addWithPaintTransform( + transform: _transform, + position: position, + hitTest: (BoxHitTestResult result, Offset position) { + final tChild = child; + + if (tChild is RenderBox) { + return tChild.hitTest(result, position: position); + } + + return false; + }, + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) { + return; + } + + final Offset? tChildOffset = MatrixUtils.getAsTranslation(_transform); + + if (tChildOffset == null) { + // if the matrix is singular the children would be compressed to a line or + // single point, instead short-circuit and paint nothing. + final double tDet = _transform.determinant(); + // coverage:ignore-start + if (tDet == 0 || !tDet.isFinite) { + layer = null; + return; + } + // coverage:ignore-end + + layer = context.pushTransform( + needsCompositing, + offset, + _transform, + (context, offset) { + context.paintChild(child!, offset); + }, + oldLayer: layer is TransformLayer ? layer as TransformLayer? : null, + ); + } else { + context.paintChild(child!, offset + tChildOffset); + layer = null; + } + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.multiply(_transform); + } +} + +/// An extension class that adds the screen layout scaling functionality to [BuildContext]. +extension ScreenLayoutContext on BuildContext { + /// The scale value of the screen layout. + double get screenLayoutScale { + double tScale = 1.0; + + visitAncestorElements((element) { + final tRenderObject = element.renderObject; + + if (tRenderObject is _ScreenLayoutRenderObject) { + tScale = tRenderObject._scale; + return false; + } else if (tRenderObject is _ScreenLayoutDisableRenderObject) { + return false; + } + + return true; + }); + + return tScale; + } +} diff --git a/packages/patapata_core/lib/src/widgets/standard_app.dart b/packages/patapata_core/lib/src/widgets/standard_app.dart new file mode 100644 index 0000000..85e4e6e --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/standard_app.dart @@ -0,0 +1,528 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:provider/provider.dart'; + +part 'standard_page.dart'; +part 'standard_page_widget.dart'; +part 'standard_app_mixin.dart'; +part 'standard_material_app.dart'; +part 'standard_cupertino_app.dart'; + +final _logger = Logger('patapata.StandardApp'); + +/// The [BuildContext] directly below [StandardMaterialApp]'s [Navigator] +Element? _findTreeChildElement(List tree) { + final Element? tRoot = WidgetsBinding.instance.rootElement; + Element? tResult; + + if (tRoot == null) { + return null; + } + for (Type tElementType in tree) { + tResult = _findChildElement(tResult ?? tRoot, tElementType); + + if (tResult == null) { + return null; + } + } + + tResult!.visitChildElements((tChild) { + tResult = tChild; + }); + + return tResult; +} + +Element? _findChildElement( + Element element, + Type elementType, +) { + Element? tResult; + + if (element.widget.runtimeType == elementType) { + return element; + } else { + element.visitChildElements((tChild) { + tResult = _findChildElement(tChild, elementType); + }); + + return tResult; + } +} + +/// A typedef for the key of a `LinkHandler` used in [StandardAppPlugin]. +typedef StandardAppPluginLinkHandlerKey = Object; + +/// A plugin that embodies the fundamental concepts of Patapata, +/// which includes rules, pages, and settings for app development, known as `StandardApp`. +/// This plugin is enabled by default. +/// If you want to disable `StandardApp`, you can remove this plugin using [App.removePlugin] +/// or disable it through remote configuration settings after running. +class StandardAppPlugin extends Plugin with StartupNavigatorMixin { + StandardRouterDelegate? _delegate; + + /// The [RouterDelegate] required for Patapata's `Router`. + StandardRouterDelegate? get delegate => _delegate; + + StandardRouteInformationParser? _parser; + + /// The [RouteInformationParser] required for Patapata's `Router`. + StandardRouteInformationParser? get parser => _parser; + + StreamSubscription? _sub; + + @override + FutureOr init(App app) async { + await super.init(app); + + _sub = app.analytics.events.listen((event) { + if (event.name == 'wefwef') { + // do something + } + }); + + return true; + } + + @override + FutureOr dispose() { + _sub?.cancel(); + return super.dispose(); + } + + final _linkHandlers = {}; + + /// Add a callback function [callback] to the plugin's link handlers and return the key that identifies the added link handler. + /// The link handler intercepts the link provided as an argument in [StandardAppRouterContext.route]. + /// It is used when you want to perform additional processing when receiving deep links or external notifications without triggering page navigation. + StandardAppPluginLinkHandlerKey addLinkHandler( + bool Function(Uri link) callback) { + final tKey = StandardAppPluginLinkHandlerKey(); + + _linkHandlers[tKey] = callback; + + return tKey; + } + + /// Remove a link handler from the plugin that matches the specified key [key]. + void removeLinkHandler(StandardAppPluginLinkHandlerKey key) { + _linkHandlers.remove(key); + } + + /// {@template patapata_widgets.StandardAppPlugin.route} + /// Navigate to a page with the specified [link]. + /// [link] is a string set in [StandardPageFactory] under `links`. + /// {@endtemplate} + void route(String link) async { + final tRouteInformation = await parser + ?.parseRouteInformation(RouteInformation(uri: Uri.parse(link))); + + if (tRouteInformation != null) { + delegate?.routeWithConfiguration(tRouteInformation); + } + } + + /// {@template patapata_widgets.StandardAppPlugin.generateLinkWithResult} + /// Retrieve a deep link for the specified [pageData] (when the page that retrieves the deep link returns a value). + /// [P] is the type of the destination page, [R] is the type of page data, and [E] is the data type of the value that the page returns. + /// These should be the same as what you set in your [StandardPageWithResultFactory]. + /// {@endtemplate} + String? generateLinkWithResult

, + R extends Object?, E extends Object?>(R pageData) { + return delegate?.getPageFactory().linkGenerator?.call(pageData); + } + + /// {@template patapata_widgets.StandardAppPlugin.generateLink} + /// Retrieve a deep link for the specified [pageData] (when the page that retrieves the deep link does not return a value). + /// [P] is the type of the destination page, [R] is the type of page data, and they should be consistent with what you have set in your [StandardPage]. + /// {@endtemplate} + String? generateLink

, R extends Object?>( + R pageData) { + return delegate?.getPageFactory().linkGenerator?.call(pageData); + } + + @override + void startupNavigateToPage(Object page, StartupPageCompleter completer) { + assert(page is Type); + + delegate?._getFactoryFromPageType(page as Type).goWithResult(completer); + } + + @override + void startupProcessInitialRoute() { + delegate?.processInitialRoute(); + } + + @override + void startupOnReset() { + delegate?._factoryTypeMap.values.first + .goWithResult(null, StandardPageNavigationMode.removeAll); + } +} + +/// A mixin for enabling integration between the 'Patapata' [Route] system and [Plugin], including routing and initialization processes. +/// The method implemented in this mixin is called within the processing of [StandardRouterDelegate.processInitialRoute] +/// when transitioning to the initial page after the app is launched. +/// +/// In each plugin, please override and implement the following functions as needed. +/// [parseRouteInformation] is responsible for parsing route information and implementing the process to convert it into [StandardRouteData]. This is implemented when you want to return custom routes on the plugin side. +/// [transformRouteInformation] is used to implement the transformation process of route information in the plugin, primarily when you want to handle redirection. +/// [getInitialRouteData] is used to implement the logic for overwriting the data of the Route before processing the initial route. +mixin StandardAppRoutePluginMixin on Plugin { + /// A function that parses the route information [routeInformation] and converts it to [StandardRouteData] for [StandardRouterDelegate]. + /// If the plugin's [parseRouteInformation] is implemented and returns a route, the processing of [transformRouteInformation] is ignored. + Future parseRouteInformation( + RouteInformation routeInformation) => + SynchronousFuture(null); + + /// A function to transform the route information [routeInformation] into another transformed route information [RouteInformation]. + /// This needs to be implemented when you want to redirect from one route to another based on the received route on the plugin side. + Future transformRouteInformation( + RouteInformation routeInformation) => + SynchronousFuture(null); + + /// The process of creating [StandardRouteData] to be passed from the plugin to the screen, and this data is passed to [StandardRouterDelegate.routeWithConfiguration]. + /// If multiple plugins implement [getInitialRouteData], the [getInitialRouteData] of the first found plugin will be executed. + Future getInitialRouteData() => SynchronousFuture(null); + + /// Create a [StandardAppRoutePluginMixin] that can be used in [App.addPlugin]. + /// This takes all the same parameters as [Plugin.inline] as well as all the methods of [StandardAppRoutePluginMixin]. + static StandardAppRoutePluginMixin inline({ + String name = 'inline', + List dependencies = const [], + bool requireRemoteConfig = false, + FutureOr Function(App app)? init, + FutureOr Function()? dispose, + Widget Function(Widget child)? createAppWidgetWrapper, + RemoteConfig? Function()? createRemoteConfig, + LocalConfig? Function()? createLocalConfig, + RemoteMessaging? Function()? createRemoteMessaging, + List Function()? navigatorObservers, + Future Function(RouteInformation routeInformation)? + parseRouteInformation, + Future Function(RouteInformation routeInformation)? + transformRouteInformation, + Future Function()? getInitialRouteData, + }) => + _StandardAppRoutePluginMixinInline( + name: name, + dependencies: dependencies, + requireRemoteConfig: requireRemoteConfig, + init: init, + dispose: dispose, + createAppWidgetWrapper: createAppWidgetWrapper, + createRemoteConfig: createRemoteConfig, + createLocalConfig: createLocalConfig, + createRemoteMessaging: createRemoteMessaging, + navigatorObservers: navigatorObservers, + parseRouteInformation: parseRouteInformation, + transformRouteInformation: transformRouteInformation, + getInitialRouteData: getInitialRouteData, + ); +} + +class _StandardAppRoutePluginMixinInline extends InlinePlugin + with StandardAppRoutePluginMixin { + final Future Function(RouteInformation routeInformation)? + _parseRouteInformation; + final Future Function(RouteInformation routeInformation)? + _transformRouteInformation; + final Future Function()? _getInitialRouteData; + + _StandardAppRoutePluginMixinInline({ + required super.name, + super.dependencies, + super.requireRemoteConfig, + super.init, + super.dispose, + super.createAppWidgetWrapper, + super.createRemoteConfig, + super.createLocalConfig, + super.createRemoteMessaging, + super.navigatorObservers, + Future Function(RouteInformation routeInformation)? + parseRouteInformation, + Future Function(RouteInformation routeInformation)? + transformRouteInformation, + Future Function()? getInitialRouteData, + }) : _parseRouteInformation = parseRouteInformation, + _transformRouteInformation = transformRouteInformation, + _getInitialRouteData = getInitialRouteData; + + @override + Future parseRouteInformation( + RouteInformation routeInformation) => + _parseRouteInformation != null + ? _parseRouteInformation!(routeInformation) + : super.parseRouteInformation(routeInformation); + + @override + Future transformRouteInformation( + RouteInformation routeInformation) => + _transformRouteInformation != null + ? _transformRouteInformation!(routeInformation) + : super.transformRouteInformation(routeInformation); + + @override + Future getInitialRouteData() => + _getInitialRouteData != null + ? _getInitialRouteData!() + : super.getInitialRouteData(); +} + +/// A mixin for allowing a [Plugin] to modify how a [StandardPage] or [StandardPageWithResult] works. +mixin StandardPagePluginMixin on Plugin { + /// A function that allows you to modify the [StandardPage] or [StandardPageWithResult] before it is displayed. + /// It will directly wrap the result of [StandardPage.buildPage] or [StandardPageWithResult.buildPage]. + /// + /// When there are multiple [Plugin]s, each [Plugin] will wrap the result of the previous [Plugin]. + /// In other words, the result of the first [Plugin] will be wrapped by the second [Plugin], and so on. + Widget buildPage(BuildContext context, Widget child); + + /// Create a [StandardPagePluginMixin] that can be used in [App.addPlugin]. + /// This takes all the same parameters as [Plugin.inline] as well as all the methods of [StandardPagePluginMixin]. + static StandardPagePluginMixin inline({ + String name = 'inline', + List dependencies = const [], + bool requireRemoteConfig = false, + FutureOr Function(App app)? init, + FutureOr Function()? dispose, + Widget Function(Widget child)? createAppWidgetWrapper, + RemoteConfig? Function()? createRemoteConfig, + LocalConfig? Function()? createLocalConfig, + RemoteMessaging? Function()? createRemoteMessaging, + List Function()? navigatorObservers, + required Widget Function(BuildContext context, Widget child) buildPage, + }) => + _StandardPagePluginMixinInline( + name: name, + dependencies: dependencies, + requireRemoteConfig: requireRemoteConfig, + init: init, + dispose: dispose, + createAppWidgetWrapper: createAppWidgetWrapper, + createRemoteConfig: createRemoteConfig, + createLocalConfig: createLocalConfig, + createRemoteMessaging: createRemoteMessaging, + navigatorObservers: navigatorObservers, + buildPage: buildPage, + ); +} + +class _StandardPagePluginMixinInline extends InlinePlugin + with StandardPagePluginMixin { + final Widget Function(BuildContext context, Widget child) _buildPage; + + _StandardPagePluginMixinInline({ + required super.name, + super.dependencies, + super.requireRemoteConfig, + super.init, + super.dispose, + super.createAppWidgetWrapper, + super.createRemoteConfig, + super.createLocalConfig, + super.createRemoteMessaging, + super.navigatorObservers, + required Widget Function(BuildContext context, Widget child) buildPage, + }) : _buildPage = buildPage; + + @override + Widget buildPage(BuildContext context, Widget child) { + return _buildPage(context, child); + } +} + +/// An extension class that adds the Router functionality of StandardApp to [Router]. +extension StandardAppRouter on Router { + /// {@macro patapata_widgets.StandardRouteDelegate.pageInstances} + List> get pageInstances { + assert(routerDelegate is StandardRouterDelegate); + final tDelegate = routerDelegate as StandardRouterDelegate; + + return tDelegate.pageInstances; + } + + /// {@template patapata_widgets.StandardAppRouter.pageChildInstances} + /// Get a List of the actual pages of [Page]. + /// {@endtemplate} + Map>> get pageChildInstances { + assert(routerDelegate is StandardRouterDelegate); + final tDelegate = routerDelegate as StandardRouterDelegate; + + return tDelegate._pageChildInstances; + } + + /// {@macro patapata_widgets.StandardRouteDelegate.getPageFactory} + StandardPageWithResultFactory getPageFactory< + T extends StandardPageWithResult, + R extends Object?, + E extends Object?>() { + assert(routerDelegate is StandardRouterDelegate); + final tDelegate = routerDelegate as StandardRouterDelegate; + + return tDelegate.getPageFactory(); + } + + /// {@macro patapata_widgets.StandardRouteDelegate.processInitialRoute} + void processInitialRoute() { + assert(routerDelegate is StandardRouterDelegate); + (routerDelegate as StandardRouterDelegate).processInitialRoute(); + } + + /// {@macro patapata_widgets.StandardRouteDelegate.goWithResult} + Future goWithResult, + R extends Object?, E extends Object?>(R pageData, + [StandardPageNavigationMode? navigationMode]) { + assert(routerDelegate is StandardRouterDelegate); + final tDelegate = routerDelegate as StandardRouterDelegate; + return tDelegate.goWithResult(pageData, navigationMode); + } + + /// {@macro patapata_widgets.StandardRouteDelegate.go} + Future go, R extends Object?>(R pageData, + [StandardPageNavigationMode? navigationMode]) { + assert(routerDelegate is StandardRouterDelegate); + final tDelegate = routerDelegate as StandardRouterDelegate; + return tDelegate.go(pageData, navigationMode); + } + + /// {@template patapata_widgets.StandardAppRouter.route} + /// Navigate to a page with the specified [location]. + /// [navigationMode] represents the mode of [StandardPageNavigationMode] to use during navigation (optional). + /// {@endtemplate} + void route(String location, + [StandardPageNavigationMode? navigationMode]) async { + assert(routerDelegate is StandardRouterDelegate); + final tDelegate = routerDelegate as StandardRouterDelegate; + final tConfiguration = await routeInformationParser + ?.parseRouteInformation(RouteInformation(uri: Uri.parse(location))); + + if (tConfiguration != null) { + tDelegate.routeWithConfiguration(tConfiguration, navigationMode); + } + } + + /// Remove the nearest page in [context], if it exists. Do nothing if it doesn't exist. + void removeRoute(BuildContext context) { + assert(routerDelegate is StandardRouterDelegate); + final tDelegate = routerDelegate as StandardRouterDelegate; + + final tRoute = ModalRoute.of(context); + + if (tRoute == null) { + return; + } + + tDelegate.removeRoute(tRoute, null); + } +} + +/// An extension class that adds the Router functionality of StandardApp to [BuildContext]. +extension StandardAppRouterContext on BuildContext { + /// Retrieves the [Router]. + Router get router => Router.of(this); + + /// {@macro patapata_widgets.StandardRouteDelegate.goWithResult} + Future goWithResult, + R extends Object?, E extends Object?>(R pageData, + [StandardPageNavigationMode? navigationMode]) { + return Router.of(this).goWithResult(pageData, navigationMode); + } + + /// {@macro patapata_widgets.StandardRouteDelegate.go} + Future go, R extends Object?>(R pageData, + [StandardPageNavigationMode? navigationMode]) { + return Router.of(this).go(pageData, navigationMode); + } + + /// {@macro patapata_widgets.StandardAppRouter.route} + void route(String location, [StandardPageNavigationMode? navigationMode]) { + Router.of(this).route(location, navigationMode); + } + + /// {@macro patapata_widgets.StandardRouteDelegate.pageInstances} + List> get pageInstances => Router.of(this).pageInstances; + + /// {@macro patapata_widgets.StandardAppRouter.pageChildInstances} + Map>> get pageChildInstances => + Router.of(this).pageChildInstances; + + /// {@macro patapata_widgets.StandardRouteDelegate.getPageFactory} + StandardPageWithResultFactory getPageFactory< + T extends StandardPageWithResult, + R extends Object?, + E extends Object?>() { + return Router.of(this).getPageFactory(); + } + + /// {@macro patapata_widgets.StandardRouteDelegate.removeRoute} + void removeRoute() { + Router.of(this).removeRoute(this); + } +} + +/// An extension class that adds references to the Router and Plugin functionalities of StandardApp to [App]. +extension StandardAppApp on App { + /// Retrieves [StandardAppPlugin] from the [App]. + StandardAppPlugin get standardAppPlugin { + final tPlugin = getPlugin(); + + assert(tPlugin != null, + 'Could not find StandardApp. Was it removed from the plugins?'); + + return tPlugin!; + } + + /// The BuildContext of the Navigator from [StandardAppPlugin.delegate]. + BuildContext get navigatorContext => + standardAppPlugin.delegate!.navigatorContext; + + /// The Navigator from [StandardAppPlugin.delegate]. + NavigatorState get navigator => standardAppPlugin.delegate!.navigator; + + /// {@macro patapata_widgets.StandardRouteDelegate.goWithResult} + Future goWithResult, + R extends Object?, E extends Object?>(R pageData, + [StandardPageNavigationMode? navigationMode]) { + return navigatorContext.goWithResult(pageData, navigationMode); + } + + /// {@macro patapata_widgets.StandardRouteDelegate.goWithResult} + Future go, R extends Object?>(R pageData, + [StandardPageNavigationMode? navigationMode]) { + return navigatorContext.go(pageData, navigationMode); + } + + /// {@macro patapata_widgets.StandardAppPlugin.route} + void route(String link) async { + return standardAppPlugin.route(link); + } + + /// Pops the Navigator of [StandardAppApp.navigator]. + /// This is used when context is not accessible. + void removeRoute() { + navigator.pop(); + } + + /// {@macro patapata_widgets.StandardAppPlugin.generateLinkWithResult} + String? generateLinkWithResult

, + R extends Object?, E extends Object?>(R pageData) { + return standardAppPlugin.generateLinkWithResult(pageData); + } + + /// {@macro patapata_widgets.StandardAppPlugin.generateLink} + String? generateLink

, R extends Object?>( + R pageData) { + return standardAppPlugin.generateLink(pageData); + } +} diff --git a/packages/patapata_core/lib/src/widgets/standard_app_mixin.dart b/packages/patapata_core/lib/src/widgets/standard_app_mixin.dart new file mode 100644 index 0000000..1cbac1c --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/standard_app_mixin.dart @@ -0,0 +1,63 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +part of "standard_app.dart"; + +/// This is a mixin for StatefulWidget that is needed to create a `WidgetsApp.Router` for Patapata. +/// If you want to create your own `WidgetsApp.Router`, you can refer to [StandardMaterialApp] or [StandardCupertinoApp]. +mixin StandardStatefulMixin on StatefulWidget { + /// A list of [StandardPageWithResultFactory] + List get pages; + + /// Wrap the entire Patapata Navigator-related application, + /// enabling the use of page transition-related functionalities through a function. + Widget Function(BuildContext context, Widget? child)? get routableBuilder; + + /// A function called when the app is attempting to go back to the previous page. + bool Function(Route route, dynamic result)? get willPopPage; +} + +/// This is a mixin for State that is needed to create a `WidgetsApp.Router` for Patapata. +/// If you want to create your own `WidgetsApp.Router`, you can refer to [StandardMaterialApp] or [StandardCupertinoApp]. +mixin StandardWidgetAppMixin on State { + late StandardRouteInformationParser _routeInformationParser; + late StandardRouterDelegate _routerDelegate; + + @override + void initState() { + super.initState(); + + _routerDelegate = StandardRouterDelegate( + context: context, + pageFactories: widget.pages, + routableBuilder: widget.routableBuilder, + willPopPage: widget.willPopPage, + ); + _routeInformationParser = StandardRouteInformationParser( + context: context, + routerDelegate: _routerDelegate, + ); + final tPlugin = context.read().getPlugin(); + tPlugin?._delegate = _routerDelegate; + tPlugin?._parser = _routeInformationParser; + } + + @override + void didUpdateWidget(covariant T oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.routableBuilder != oldWidget.routableBuilder) { + _routerDelegate.routableBuilder = widget.routableBuilder; + } + + if (widget.willPopPage != oldWidget.willPopPage) { + _routerDelegate.willPopPage = widget.willPopPage; + } + + if (!const DeepCollectionEquality().equals(widget.pages, oldWidget.pages)) { + _routerDelegate._updatePageFactories(widget.pages); + } + } +} diff --git a/packages/patapata_core/lib/src/widgets/standard_cupertino_app.dart b/packages/patapata_core/lib/src/widgets/standard_cupertino_app.dart new file mode 100644 index 0000000..e12ffa0 --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/standard_cupertino_app.dart @@ -0,0 +1,168 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +part of "standard_app.dart"; + +/// This class is used to create applications using Cupertino with Patapata. +/// Widgets that have this class as their parent cannot use widgets intended for use with [MaterialApp]. +/// Properties other than [pages], [routableBuilder], and [willPopPage] are the same as [CupertinoApp]. +class StandardCupertinoApp extends StatefulWidget + with StandardStatefulMixin { + /// {@macro flutter.widgets.widgetsApp.routeInformationProvider} + final RouteInformationProvider? routeInformationProvider; + + /// {@macro flutter.widgets.widgetsApp.backButtonDispatcher} + final BackButtonDispatcher? backButtonDispatcher; + + /// {@macro flutter.widgets.widgetsApp.routerConfig} + final RouterConfig? routerConfig; + + /// {@macro flutter.widgets.widgetsApp.builder} + final TransitionBuilder? builder; + + /// {@macro flutter.widgets.widgetsApp.onGenerateTitle} + final String Function(BuildContext) onGenerateTitle; + + /// A CupertinoThemeData to pass to [theme] of [CupertinoApp.router]. + /// See [CupertinoApp.theme] of [CupertinoApp] for more details. + final CupertinoThemeData? theme; + + /// {@macro flutter.widgets.widgetsApp.color} + final Color? color; + + /// {@macro flutter.widgets.widgetsApp.locale} + final Locale? locale; + + /// {@macro flutter.widgets.widgetsApp.localeListResolutionCallback} + final LocaleListResolutionCallback? localeListResolutionCallback; + + /// {@macro flutter.widgets.LocaleResolutionCallback} + final LocaleResolutionCallback? localeResolutionCallback; + + /// A bool to pass to [showPerformanceOverlay] of [CupertinoApp.router]. + /// See [CupertinoApp.showPerformanceOverlay] of [CupertinoApp] for more details. + final bool showPerformanceOverlay; + + /// A bool to pass to [checkerboardRasterCacheImages] of [CupertinoApp.router]. + /// See [CupertinoApp.checkerboardRasterCacheImages] of [CupertinoApp] for more details. + final bool checkerboardRasterCacheImages; + + /// A bool to pass to [checkerboardOffscreenLayers] of [CupertinoApp.router]. + /// See [CupertinoApp.checkerboardOffscreenLayers] of [CupertinoApp] for more details. + final bool checkerboardOffscreenLayers; + + /// A bool to pass to [showSemanticsDebugger] of [CupertinoApp.router]. + /// See [CupertinoApp.showSemanticsDebugger] of [CupertinoApp] for more details. + final bool showSemanticsDebugger; + + /// {@macro flutter.widgets.widgetsApp.debugShowCheckedModeBanner} + final bool debugShowCheckedModeBanner; + + /// {@macro flutter.widgets.widgetsApp.shortcuts.seeAlso} + final Map? shortcuts; + + /// {@macro flutter.widgets.widgetsApp.actions.seeAlso} + final Map>? actions; + + /// {@macro flutter.widgets.widgetsApp.restorationScopeId} + final String? restorationScopeId; + + /// {@macro flutter.material.materialApp.scrollBehavior} + final ScrollBehavior? scrollBehavior; + + final List _pages; + final Widget Function(BuildContext context, Widget? child)? _routableBuilder; + final bool Function(Route route, dynamic result)? _willPopPage; + + /// A list of pages using the [StandardPageFactory] class implemented with StandardMaterialApp. + /// It is passed to the pageFactories of [StandardRouterDelegate]. + @override + List< + StandardPageWithResultFactory, + Object?, Object?>> get pages => _pages; + + /// Wrap the entire Patapata Navigator-related application, + /// enabling the use of screen transition-related functionalities through a function. + /// It is passed to the routableBuilder of [StandardRouterDelegate]. + @override + Widget Function(BuildContext context, Widget? child)? get routableBuilder => + _routableBuilder; + + /// A function called when the app goes back to the previous page. + /// It is passed to the willPopPage of [StandardRouterDelegate]. + @override + bool Function(Route route, dynamic result)? get willPopPage => + _willPopPage; + + /// Creates a StandardCupertinoApp. + const StandardCupertinoApp({ + Key? key, + this.routeInformationProvider, + this.backButtonDispatcher, + this.routerConfig, + this.builder, + required this.onGenerateTitle, + this.theme, + this.color, + this.locale, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + this.shortcuts, + this.actions, + this.restorationScopeId, + this.scrollBehavior, + required List pages, + Widget Function(BuildContext context, Widget? child)? routableBuilder, + bool Function(Route route, dynamic result)? willPopPage, + }) : _pages = pages, + _routableBuilder = routableBuilder, + _willPopPage = willPopPage, + super(key: key); + + @override + State createState() => _StandardCupertinoAppState(); +} + +class _StandardCupertinoAppState extends State> + with StandardWidgetAppMixin { + @override + Widget build(BuildContext context) { + return Provider.value( + value: StandardAppType.cupertino, + child: CupertinoApp.router( + routeInformationProvider: widget.routeInformationProvider, + routeInformationParser: _routeInformationParser, + routerDelegate: _routerDelegate, + backButtonDispatcher: widget.backButtonDispatcher, + routerConfig: widget.routerConfig, + builder: widget.builder, + onGenerateTitle: widget.onGenerateTitle, + color: widget.color, + theme: widget.theme, + locale: widget.locale, + localizationsDelegates: + context.read().getPlugin()!.i18n.l10nDelegates, + localeListResolutionCallback: widget.localeListResolutionCallback, + localeResolutionCallback: widget.localeResolutionCallback, + supportedLocales: + context.read().getPlugin()!.i18n.supportedL10ns, + showPerformanceOverlay: widget.showPerformanceOverlay, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + shortcuts: widget.shortcuts, + actions: widget.actions, + restorationScopeId: widget.restorationScopeId, + scrollBehavior: widget.scrollBehavior, + ), + ); + } +} diff --git a/packages/patapata_core/lib/src/widgets/standard_material_app.dart b/packages/patapata_core/lib/src/widgets/standard_material_app.dart new file mode 100644 index 0000000..ed20316 --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/standard_material_app.dart @@ -0,0 +1,204 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +part of "standard_app.dart"; + +/// This class is used to create applications using Material Design with Patapata. +/// Widgets that have this class as their parent cannot use widgets intended for use with [CupertinoApp]. +/// Properties other than [pages], [routableBuilder], and [willPopPage] are properties to be passed to [MaterialApp.router]. +class StandardMaterialApp extends StatefulWidget with StandardStatefulMixin { + /// A GlobalKey to pass to [scaffoldMessengerKey] of [MaterialApp.router]. + /// See [MaterialApp.scaffoldMessengerKey] of [MaterialApp] for more details. + final GlobalKey? scaffoldMessengerKey; + + /// {@macro flutter.widgets.widgetsApp.routeInformationProvider} + final RouteInformationProvider? routeInformationProvider; + + /// {@macro flutter.widgets.widgetsApp.backButtonDispatcher} + final BackButtonDispatcher? backButtonDispatcher; + + /// {@macro flutter.widgets.widgetsApp.builder} + final TransitionBuilder? builder; + + /// {@macro flutter.widgets.widgetsApp.onGenerateTitle} + final String Function(BuildContext) onGenerateTitle; + + /// A ThemeData to pass to [theme] of [MaterialApp.router]. + /// See [MaterialApp.theme] of [MaterialApp] for more details. + final ThemeData? theme; + + /// A ThemeData to pass to [darkTheme] of [MaterialApp.router]. + /// See [MaterialApp.darkTheme] of [MaterialApp] for more details. + final ThemeData? darkTheme; + + /// A ThemeData to pass to [highContrastTheme] of [MaterialApp.router]. + /// See [MaterialApp.highContrastTheme] of [MaterialApp] for more details. + final ThemeData? highContrastTheme; + + /// A ThemeData to pass to [highContrastDarkTheme] of [MaterialApp.router]. + /// See [MaterialApp.highContrastDarkTheme] of [MaterialApp] for more details. + final ThemeData? highContrastDarkTheme; + + /// A ThemeMode to pass to [themeMode] of [MaterialApp.router]. + /// See [MaterialApp.themeMode] of [MaterialApp] for more details. + final ThemeMode? themeMode; + + /// {@macro flutter.widgets.widgetsApp.color} + final Color? color; + + /// {@macro flutter.widgets.widgetsApp.locale} + final Locale? locale; + + /// {@macro flutter.widgets.widgetsApp.localeListResolutionCallback} + final LocaleListResolutionCallback? localeListResolutionCallback; + + /// {@macro flutter.widgets.LocaleResolutionCallback} + final LocaleResolutionCallback? localeResolutionCallback; + + /// A bool to pass to [showPerformanceOverlay] of [MaterialApp.router]. + /// See [MaterialApp.showPerformanceOverlay] of [MaterialApp] for more details. + final bool showPerformanceOverlay; + + /// A bool to pass to [checkerboardRasterCacheImages] of [MaterialApp.router]. + /// See [MaterialApp.checkerboardRasterCacheImages] of [MaterialApp] for more details. + final bool checkerboardRasterCacheImages; + + /// A bool to pass to [checkerboardOffscreenLayers] of [MaterialApp.router]. + /// See [MaterialApp.checkerboardOffscreenLayers] of [MaterialApp] for more details. + final bool checkerboardOffscreenLayers; + + /// A bool to pass to [showSemanticsDebugger] of [MaterialApp.router]. + /// See [MaterialApp.showSemanticsDebugger] of [MaterialApp] for more details. + final bool showSemanticsDebugger; + + /// A bool to pass to [debugShowCheckedModeBanner] of [MaterialApp.router]. + /// See [MaterialApp.debugShowCheckedModeBanner] of [MaterialApp] for more details. + final bool debugShowCheckedModeBanner; + + /// {@macro flutter.widgets.widgetsApp.shortcuts.seeAlso} + final Map? shortcuts; + + /// {@macro flutter.widgets.widgetsApp.actions.seeAlso} + final Map>? actions; + + /// {@macro flutter.widgets.widgetsApp.restorationScopeId} + final String? restorationScopeId; + + /// A ScrollBehavior to pass to [scrollBehavior] of [MaterialApp.router]. + /// See [MaterialApp.scrollBehavior] of [MaterialApp] for more details. + final ScrollBehavior? scrollBehavior; + + /// A bool to pass to [debugShowMaterialGrid] of [MaterialApp.router]. + /// See [MaterialApp.debugShowMaterialGrid] of [MaterialApp] for more details. + final bool debugShowMaterialGrid; + + final List _pages; + final Widget Function(BuildContext context, Widget? child)? _routableBuilder; + final bool Function(Route route, dynamic result)? _willPopPage; + + /// A list of pages using the [StandardPageFactory] class implemented with StandardMaterialApp. + /// It is passed to the pageFactories of [StandardRouterDelegate]. + @override + List< + StandardPageWithResultFactory, + Object?, Object?>> get pages => _pages; + + /// Wrap the entire Patapata Navigator-related application, + /// enabling the use of screen transition-related functionalities through a function. + /// It is passed to the routableBuilder of [StandardRouterDelegate]. + @override + Widget Function(BuildContext context, Widget? child)? get routableBuilder => + _routableBuilder; + + /// See [PopScope] for more details. + /// It is passed to the willPopPage of [StandardRouterDelegate]. + @override + bool Function(Route route, dynamic result)? get willPopPage => + _willPopPage; + + /// BuildContext directly under Navigator of StandardMaterialApp + static Element? get globalNavigatorContext => + _findTreeChildElement([StandardMaterialApp, Navigator]); + + /// Creates a StandardMaterialApp. + const StandardMaterialApp({ + Key? key, + this.scaffoldMessengerKey, + this.routeInformationProvider, + this.backButtonDispatcher, + this.builder, + required this.onGenerateTitle, + this.theme, + this.darkTheme, + this.highContrastTheme, + this.highContrastDarkTheme, + this.themeMode, + this.color, + this.locale, + this.localeListResolutionCallback, + this.localeResolutionCallback, + this.debugShowMaterialGrid = false, + this.showPerformanceOverlay = false, + this.checkerboardRasterCacheImages = false, + this.checkerboardOffscreenLayers = false, + this.showSemanticsDebugger = false, + this.debugShowCheckedModeBanner = true, + this.shortcuts, + this.actions, + this.restorationScopeId, + this.scrollBehavior, + required List pages, + Widget Function(BuildContext context, Widget? child)? routableBuilder, + bool Function(Route route, dynamic result)? willPopPage, + }) : _pages = pages, + _routableBuilder = routableBuilder, + _willPopPage = willPopPage, + super(key: key); + + @override + State> createState() => _StandardMaterialAppState(); +} + +class _StandardMaterialAppState extends State> + with StandardWidgetAppMixin { + @override + Widget build(BuildContext context) { + return Provider.value( + value: StandardAppType.material, + child: MaterialApp.router( + scaffoldMessengerKey: widget.scaffoldMessengerKey, + routeInformationProvider: widget.routeInformationProvider, + routeInformationParser: _routeInformationParser, + routerDelegate: _routerDelegate, + backButtonDispatcher: widget.backButtonDispatcher, + builder: widget.builder, + onGenerateTitle: widget.onGenerateTitle, + color: widget.color, + theme: widget.theme, + darkTheme: widget.darkTheme, + highContrastTheme: widget.highContrastTheme, + highContrastDarkTheme: widget.highContrastDarkTheme, + themeMode: widget.themeMode, + locale: widget.locale, + localizationsDelegates: + context.read().getPlugin()!.i18n.l10nDelegates, + localeListResolutionCallback: widget.localeListResolutionCallback, + localeResolutionCallback: widget.localeResolutionCallback, + supportedLocales: + context.read().getPlugin()!.i18n.supportedL10ns, + debugShowMaterialGrid: widget.debugShowMaterialGrid, + showPerformanceOverlay: widget.showPerformanceOverlay, + checkerboardRasterCacheImages: widget.checkerboardRasterCacheImages, + checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers, + showSemanticsDebugger: widget.showSemanticsDebugger, + debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, + shortcuts: widget.shortcuts, + actions: widget.actions, + restorationScopeId: widget.restorationScopeId, + scrollBehavior: widget.scrollBehavior, + ), + ); + } +} diff --git a/packages/patapata_core/lib/src/widgets/standard_page.dart b/packages/patapata_core/lib/src/widgets/standard_page.dart new file mode 100644 index 0000000..ee6b5aa --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/standard_page.dart @@ -0,0 +1,1952 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +part of "standard_app.dart"; + +const Key _childNavigatorKey = Key('childNavigatorKey'); + +/// Whether to use the Material app or the Cupertino app to design +enum StandardAppType { + /// Use standard material app + material, + + /// Use standard cupertino app + cupertino, +} + +/// Specifies how pages created with [StandardPageFactory] should be navigated +enum StandardPageNavigationMode { + /// Similar to [Navigator.push] used in Flutter's Navigator, it adds the page to the top of the history. + /// However, if there is already a page with the same page key in the history, + /// it moves that page to the top without removing other pages with the same page key from the history + moveToTop, + + /// If the same page exists within the history, all history from that page and up will be removed. + /// Then the page will be added to the top of the history. + /// If the same page does not exist within the history, it behaves the same as [moveToTop]. + removeAbove, + + /// After removing all pages from the history, add the page. + removeAll, + + /// Remove the current page from the history and replace it with the page being navigated to. + replace, +} + +/// A factory class for creating [StandardPageWithResult] instances that returns a value [E]. +/// This class can be added to the `pages` property of [StandardMaterialApp.new] or [StandardCupertinoApp.new]. +/// [T] is the Represents the data type of the destination page, [R] is the Represents the data type of the page data, [E] is the Represents the data type of the value returned by the page. +/// +/// For how to pass and configure this class in [StandardStatefulMixin.pages], please refer to the documentation of [StandardPageFactory]. +/// +/// The key distinction to [StandardPage] is that this class returns a value. +/// For example, a `PageWithResult` is a screen of type `StandardPageWithResult` that returns a result of type String. +/// +/// example: +/// ```dart +/// class PageWithResult extends StandardPageWithResult { +/// @override +/// Widget buildPage(BuildContext context) { +/// return Scaffold( +/// body: Center( +/// child: TextButton( +/// onPressed: () { +/// // Set the result of this StandardPageWithResult to the pageResult. +/// pageResult = 'pageResult'; +/// Navigator.pop(context); +/// }, +/// child: const Text('Navigator Pop Page'), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// You can retrieve the result of `PageWithResult` as shown below, using [goWithResult] for navigation. +/// +/// example: +/// ```dart +/// final tResult = await context.goWithResult(null); +/// ``` +base class StandardPageWithResultFactory, + R extends Object?, E extends Object?> { + /// The default group name set when no group is specified for the page. + static const String defaultGroup = 'StandardPageDefaultGroup'; + + late final StandardRouterDelegate _delegate; + + /// Creates the [T] page that this factory manages. + final T Function(R pageData) create; + final Map _links; + + /// The function to create deep links for this page. + /// The return value must match the keys (regular expressions) passed to links and their corresponding [R] 'pageData' destinations. + final String Function(R pageData)? linkGenerator; + + /// The group name used to manage multiple pages as part of the same group when they exist. + final String? group; + + /// Flag indicating whether to set this page as the root group if a group name is specified. + final bool groupRoot; + + /// Flag indicating whether to stack this page as part of the history or not. + final bool keepHistory; + + /// The method for transitioning to this page from other pages. + /// Please refer to [StandardPageNavigationMode] for navigation modes. + final StandardPageNavigationMode navigationMode; + final LocalKey Function( + R pageData, + )? _pageKey; + + /// A function for creating [StandardPageInterface]. + /// + /// The [pageBuilder] function takes the following parameters + /// `child` : The child widget to be included in the [StandardPageWithResult]. + /// `name` : The name of the page (can be null). + /// `pageData` : The data associated with the page. + /// `pageKey` : A [LocalKey] to identify the page. + /// `restorationId` : A unique ID for state restoration. + /// `standardPageKey` : A [GlobalKey] for the [StandardPageWithResult] widget. + /// `factoryObject` : An instance of [StandardPageWithResultFactory]. + final StandardPageInterface Function( + Widget child, + String? name, + R pageData, + LocalKey pageKey, + String restorationId, + GlobalKey> standardPageKey, + StandardPageWithResultFactory, R, E> + factoryObject, + )? pageBuilder; + + /// A function to generate a replacement value when the pageData passed during navigation is null. + final R Function()? pageDataWhenNull; + + /// The name of this page. + final String? Function()? pageName; + + /// A function for generating a value to pass to [Page.restorationId]. + final String Function( + R pageData, + )? restorationId; + + /// When using nested [Navigator]s, specifies what the parent page [Type] of this child page should be. + final Type? parentPageType; + + /// Create a StandardPageWithResultFactory. + /// Define deep links for navigating to this page using regular expressions, allowing for multiple configurations. + /// Set the page key, with page data being of the type [R]. + /// + /// [create] is a required parameter. Pass a class that extends [StandardPageWithResult] to this argument. + /// Other arguments are optional. + /// + /// [links] define the links to navigate to this page using regular expressions, and [linkGenerator] generates deep links for this page. + /// + /// [group] is the group name used to manage multiple pages as part of the same group. [groupRoot] is a flag within the same group name to specify which page should be the root. The default value is `false`. + /// + /// [keepHistory] is a flag for whether to push history on the Navigator during transitions. The default is `true`. + /// + /// [navigationMode] specifies the NavigationMode when navigating to this page. The default is [StandardPageNavigationMode.moveToTop]. + /// + /// [pageKey] is a [LocalKey] to identify the page. + /// + /// [pageBuilder] is a function that wraps the processing when the page is built. The default is the page of [StandardMaterialPage]. + /// + /// [pageDataWhenNull] is a function that wraps the processing when building when page data is null. + /// + /// [pageName] is the name of this page. + /// + /// [restorationId] is a unique ID for state restoration. + /// + /// [parentPageType] is the page type that specifies which page type to consider as the parent. + StandardPageWithResultFactory({ + required this.create, + Map? links, + this.linkGenerator, + this.groupRoot = false, + this.group = defaultGroup, + this.keepHistory = true, + this.navigationMode = StandardPageNavigationMode.moveToTop, + LocalKey Function( + R pageData, + )? pageKey, + this.pageBuilder, + this.pageDataWhenNull, + this.pageName, + this.restorationId, + this.parentPageType, + }) : assert((links == null && linkGenerator == null) || + (links != null && linkGenerator != null)), + _pageKey = pageKey, + _links = links != null + ? { + for (var i in links.entries) RegExp('^/?${i.key}\$'): i.value, + } + : const {}; + + /// The page type of this page. + Type get pageType => T; + + /// The data type of this page. + Type get dataType => R; + + /// The result type of this page. + Type get resultType => E; + + /// Flag indicating that the type of page data set for this page is nullable. + bool get dataTypeIsNonNullable => [] is List; + + /// Returns the deep link generated by this page given [pageData]. + String? generateLink(Object? pageData) { + if (linkGenerator != null) { + // Dart doesn't allow automatic casting 1st citizen objects who + // have different less than 1st citizen parameters. + // In this case, linkGenerator could be ((MyClass) => String), + // But dart only can see ((Object) => String) and therefore + // can't convert between them. + // We do this trick here from inside the problematic class + // to cast as something that we _know_ to be true. + return linkGenerator!(pageData as R); + } + + return null; + } + + /// Navigate to the [StandardPage] of type [T] with the option to pass [pageData] during navigation. + /// An optional [navigationMode] representing the mode of [StandardPageNavigationMode] to use during navigation can also be provided. + Future goWithResult( + R pageData, [ + StandardPageNavigationMode? navigationMode, + ]) => + _delegate.goWithResult(pageData, navigationMode); + + /// Get the key set for this page, as configured for this page. + LocalKey getPageKey(Object? pageData) { + final tPageData = pageData ?? pageDataWhenNull?.call(); + + if (tPageData is R) { + return (_pageKey ?? _defaultPageKey)(tPageData); + } + + throw Never; + } + + LocalKey _defaultPageKey( + R pageData, + ) => + ValueKey( + '${pageType.toString()}:${linkGenerator != null ? linkGenerator!(pageData) : pageData}'); + + String _defaultRestorationId( + R pageData, + ) => + '${pageType.toString()}:${linkGenerator != null ? linkGenerator!(pageData) : pageData}'; + + StandardPageInterface _defaultPageBuilder( + Widget child, + String? name, + Object? pageData, + LocalKey pageKey, + String restorationId, + GlobalKey> standardPageKey, + StandardPageWithResultFactory, R, E> + factoryObject, + ) => + StandardMaterialPage( + child: child, + name: name, + arguments: pageData, + key: pageKey, + restorationId: restorationId, + standardPageKey: standardPageKey, + factoryObject: factoryObject, + ); + + Completer _createResultCompleter() => Completer(); + + StandardPageInterface _createPage( + LocalKey pageKey, + R pageData, + ) { + if (pageData == null && pageDataWhenNull != null) { + pageData = pageDataWhenNull!(); + } + + final tGlobalKey = GlobalKey>(); + + return (pageBuilder ?? _defaultPageBuilder)( + _StandardPageWidget( + key: tGlobalKey, + factoryObject: this, + pageData: pageData, + ), + pageName?.call() ?? T.toString(), + pageData, + pageKey, + restorationId?.call(pageData) ?? _defaultRestorationId(pageData), + tGlobalKey, + this, + ); + } +} + +/// Factory class for [StandardPage] to be set in the `page` property of [StandardMaterialApp]. +/// [T] is the type of the destination page, and [R] is the type of page data. +/// The following source code is an example of passing [StandardPageFactory] to [StandardMaterialApp.pages]. +/// +/// example: +/// ```dart +/// StandardMaterialApp( +/// onGenerateTitle: (context) => 'sample', +/// pages: [ +/// StandardPageFactory( +/// create: (_) => PageA(), +/// ), +/// StandardPageFactory( +/// create: (_) => PageB(), +/// ), +/// ], +/// ); +/// ``` +/// +/// Page navigation is generally done using `context.go`. +/// You pass the type of the specified page and the page data associated with that type during navigation. +/// For pages like PageA that do not involve data transfer, you can navigate using `context.go(null)`. +/// For screens with PageBData class page data reception, you can navigate using context.go(PageBData());. +/// For pages with PageBData class as page data, you can navigate using `context.go(PageBData());`. +/// +/// There is a concept called "group" that can be configured for each page. +/// If no group is specified, it defaults to [defaultGroup]. +/// When transitioning to a group different from the current page's group, all pages other than the destination group will be removed. +/// +/// You can set deep links for each page. +/// [StandardPageFactory.new]'s `links` allows you to define regular expressions for deep links to navigate to this page. Multiple configurations are possible. +/// [linkGenerator] creates deep links to navigate to this page from the page data's state for this page. +/// +/// example: +/// ```dart +/// StandardPageFactory( +/// create: (_) => PageC(), +/// links: { +/// r'pageC/(\d+)' : (match, uri) => DeepLinkData( +/// id: int.parse(uri.queryParameters([‘id’])!), +/// message: 'this is message', +/// ), +/// }, +/// linkGenerator: (pageC) => 'pageC/${pageC.id}', +/// ), +/// +/// class DeepLinkData { +/// DeepLinkData({ +/// required this.id, +/// required this.message, +/// }); +/// final int id; +/// final String? message; +/// } +/// ``` +/// +/// For each page, you can specify which page class to use as the parent page type using `parentPageType`. +/// This is useful, for example, when implementing applications with multiple footer menus. +/// +/// example: +/// ```dart +/// StandardMaterialApp( +/// onGenerateTitle: (context) => l(context, 'title'), +/// pages: [ +/// // HomePage Menu +/// StandardPageFactory( +/// create: (data) => HomePage(), +/// ), +/// StandardPageFactory( +/// create: (data) => TitlePage(), +/// parentPageType: HomePage, +/// ), +/// StandardPageFactory( +/// create: (data) => TitleDetailsPage(), +/// parentPageType: HomePage, +/// ), +/// // MyPage Menu +/// StandardPageFactory( +/// create: (data) => MyPage(), +/// ), +/// StandardPageFactory( +/// create: (data) => MyFavoritePage(), +/// parentPageType: MyPage, +/// ), +/// ], +/// ); +/// ``` +base class StandardPageFactory, R extends Object?> + extends StandardPageWithResultFactory { + /// Create a StandardPageFactory + StandardPageFactory({ + required super.create, + super.links, + super.linkGenerator, + super.groupRoot, + super.group, + super.keepHistory, + super.navigationMode, + super.pageKey, + super.pageBuilder, + super.pageDataWhenNull, + super.pageName, + super.restorationId, + super.parentPageType, + }); +} + +/// This is a special factory class for creating a splash page after app launch, +/// and it is required to collaborate with the functionality of [StartupSequence]. +base class SplashPageFactory> + extends StandardPageFactory { + /// Create a SplashPageFactory + SplashPageFactory({ + required super.create, + super.pageKey, + super.pageBuilder, + super.pageDataWhenNull, + super.pageName, + super.restorationId, + }) : super( + group: 'splash', + keepHistory: false, + ); +} + +/// A special factory class for creating a page used during a [StartupSequence]. +/// Used for scenarios like creating a consent screen for the user on the first app launch. +base class StartupPageFactory> + extends StandardPageFactory { + /// Create a StartupPageFactory + StartupPageFactory({ + required super.create, + super.groupRoot, + String? group, + super.keepHistory, + super.navigationMode, + super.pageKey, + super.pageBuilder, + super.pageDataWhenNull, + super.pageName, + super.restorationId, + }) : super( + group: 'startup${group?.isNotEmpty == true ? '@$group' : ''}', + ); +} + +/// A special factory class for creating an error page that [PatapataException] can navigate to +/// if an error has a [PatapataException.userLogLevel] of [Level.SHOUT]. +base class StandardErrorPageFactory> + extends StandardPageFactory { + static const String errorGroup = 'error'; + + /// Create a StandardErrorPageFactory + StandardErrorPageFactory({ + required super.create, + Map? links, + super.linkGenerator, + super.groupRoot = false, + super.group = StandardErrorPageFactory.errorGroup, + super.keepHistory = true, + super.navigationMode = StandardPageNavigationMode.removeAll, + super.pageKey, + super.pageBuilder, + super.pageDataWhenNull, + super.pageName, + super.restorationId, + }); +} + +/// A mixin for creating a [Page] that creates [StandardPageWithResult]. +mixin StandardPageInterface + on Page { + /// The page key for the corresponding [StandardPageWithResult]. + GlobalKey> get standardPageKey; + + /// The factory class that created this page. + StandardPageWithResultFactory get factoryObject; +} + +/// Implements functionality to extend [MaterialPage] and create a [StandardPage]. +/// +/// This class includes the [standardPageKey] property for accessing the page's key +/// and the [factoryObject] property for obtaining an object of the StandardPageFactory class. +/// +/// [R] represents the type of the page's result. +/// [E] represents the type of the value that the page returns. +class StandardMaterialPage + extends MaterialPage implements StandardPageInterface { + @override + final GlobalKey> standardPageKey; + + @override + final StandardPageWithResultFactory factoryObject; + + /// Create a StandardMaterialPage + const StandardMaterialPage({ + LocalKey? key, + String? name, + Object? arguments, + String? restorationId, + required this.standardPageKey, + required this.factoryObject, + required Widget child, + }) : super( + key: key, + name: name, + arguments: arguments, + restorationId: restorationId, + child: child, + ); +} + +/// Implements functionality to extend [Page] and create a customized [StandardPageWithResult]. +/// +/// This class includes the [standardPageKey] property for accessing the page's key +/// and the [factoryObject] property for obtaining an object of the StandardPageWithResult class. +/// +/// [R] represents the type of the page's result. +/// [E] represents the type of the value that the page returns. +class StandardCustomPage extends Page + implements StandardPageInterface { + @override + final GlobalKey> standardPageKey; + + @override + final StandardPageWithResultFactory factoryObject; + + /// The content to be shown in the [Route] created by this page. + final Widget child; + + /// Create a StandardCustomPage + const StandardCustomPage({ + LocalKey? key, + String? name, + Object? arguments, + String? restorationId, + this.maintainState = true, + this.barrierDismissible = false, + this.barrierColor, + this.barrierLabel, + this.barrierCurve = Curves.ease, + this.opaque = true, + this.transitionDuration = const Duration(milliseconds: 300), + required this.standardPageKey, + required this.factoryObject, + this.transitionBuilder, + required this.child, + }) : super( + key: key, + name: name, + arguments: arguments, + restorationId: restorationId, + ); + + /// {@macro flutter.widgets.ModalRoute.maintainState} + final bool maintainState; + + /// {@macro flutter.widgets.ModalRoute.barrierDismissible} + final bool barrierDismissible; + + /// {@macro flutter.widgets.ModalRoute.barrierColor} + final Color? barrierColor; + + /// {@macro flutter.widgets.ModalRoute.barrierLabel} + final String? barrierLabel; + + /// {@macro flutter.widgets.ModalRoute.barrierCurve} + final Curve barrierCurve; + + /// {@macro flutter.widgets.ModalRoute.opaque} + final bool opaque; + + /// {@macro flutter.widgets.ModalRoute.transitionDuration} + final Duration transitionDuration; + + /// An animated builder process used when transitioning to this page. + final Widget Function( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + )? transitionBuilder; + + @override + Route createRoute(BuildContext context) { + return _StandardCustomPageRoute(page: this); + } +} + +class _StandardCustomPageRoute extends ModalRoute { + _StandardCustomPageRoute({ + required StandardCustomPage page, + }) : super( + settings: page, + ); + + StandardCustomPage get _page => settings as StandardCustomPage; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: _page.child, + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final tBuilder = _page.transitionBuilder; + + if (tBuilder != null) { + return tBuilder( + context, + animation, + secondaryAnimation, + child, + ); + } + + return child; + } + + @override + bool get maintainState => _page.maintainState; + + @override + Duration get transitionDuration => _page.transitionDuration; + + @override + bool get barrierDismissible => _page.barrierDismissible; + + @override + Color? get barrierColor => _page.barrierColor; + + @override + String? get barrierLabel => _page.barrierLabel; + + @override + Curve get barrierCurve => _page.barrierCurve; + + @override + bool get opaque => _page.opaque; + + @override + String get debugLabel => '${super.debugLabel}(${_page.name})'; +} + +class _StandardPageWidget + extends StatefulWidget { + final StandardPageWithResultFactory, T, E> + factoryObject; + final T pageData; + + const _StandardPageWidget({ + Key? key, + required this.factoryObject, + required this.pageData, + }) : super(key: key); + + @override + State<_StandardPageWidget> createState() => + // ignore: no_logic_in_create_state + factoryObject.create(pageData).._factory = factoryObject; +} + +const _kAnalyticsGlobalContextKey = Object(); + +/// {@template patapata_widgets.StandardPageWithResult} +/// This class is used to create pages that return values when building an application with Patapata. +/// +/// Define page classes that inherit from this class and pass them to [StandardPageWithResultFactory.create]. +/// Pages created with [StandardPageWithResult] must override [buildPage]. +/// [T] represents the type of page data associated with the page +/// {@endtemplate} +/// [E] signifies the type of data that the page returns. +abstract class StandardPageWithResult + extends State<_StandardPageWidget> with RouteAware { + late StandardPageWithResultFactory, T, E> + _factory; + + void _completeResult(Completer completer, dynamic popResult) { + if (completer.isCompleted) { + return; + } + + assert(completer is Completer); + + if (popResult != null && popResult is E) { + (completer as Completer).complete(popResult); + } else { + (completer as Completer).complete(pageResult); + } + } + + bool _pageResultSet = false; + E? _pageResult; + + /// Get the result returned by this page. + E? get pageResult => _pageResultSet ? _pageResult as E : null; + + /// Set the result returned by this page. + set pageResult(E? value) { + _pageResultSet = true; + _pageResult = value; + } + + bool _doPageDataChangedOnReady = false; + late T _pageData; + + /// Get the page data. + T get pageData => _pageData; + + /// Set the page data. + set pageData(T value) { + if (value is Listenable) { + if (_pageData != null) { + (_pageData as Listenable).removeListener(_onPageDataChanged); + } + + value.addListener(_onPageDataChanged); + } + + final tSame = _pageData == value; + _pageData = value; + + // We are dealing with the Listenable check above this + // changed check because with overridden == operators, it's + // likely that the actual object has changed and therefore the listeners + // also need to be reset, but the data part of the object is the same. + // So we just replace the listeners but don't notify of changes. + if (tSame) { + // ignore + return; + } + + _onPageDataChanged(); + } + + Navigator? _navigator; + + /// When [StandardPageWithResultFactory.parentPageType] is set, it retrieves the child [Navigator] widget. + /// This is used when creating applications with features like footer tabs. + /// + /// For example, let's say there's a page called PageA, which is a tab, and it is set as the parentPageType for PageB. + /// ```dart + /// StandardMaterialApp( + /// onGenerateTitle: (context) => l(context, 'tab page'), + /// pages: [ + /// StandardPageFactory( + /// create: (data) => PageA(), + /// ), + /// StandardPageFactory( + /// create: (data) => PageB, + /// parentPageType: PageA, + /// ), + /// ], + /// ); + /// ``` + /// + /// In this case, PageA displays the widget that shows the tab in the footer, + /// but there is no widget to display within it. Therefore, you can use [childNavigator] to retrieve and display the content of PageB + /// ```dart + /// class PageA extends StandardPage { + /// @override + /// Widget buildPage(BuildContext context) { + /// return childNavigator!; // Display the content of PageB + /// } + /// } + /// ``` + Navigator? get childNavigator => _navigator; + + void _onPageDataChanged() { + if (!_ready) { + _doPageDataChangedOnReady = true; + + return; + } + _sendPageDataEvent(); + (Router.of(context).routerDelegate as StandardRouterDelegate?) + ?._updatePages(); + onPageData(); + } + + AnalyticsContext _generateAnalyticsContext() => AnalyticsContext({ + 'pageName': name, + 'pageData': pageData, + 'pageLink': link, + ...Analytics.tryConvertToLoggableJsonParameters( + 'pageDataJson', pageData), + }); + + void _updateRouteAnalyticsContext() { + context.read().setRouteContext( + _kAnalyticsGlobalContextKey, + _generateAnalyticsContext(), + ); + } + + void _sendPageDataEvent() { + _updateRouteAnalyticsContext(); + context.read().event( + name: 'pageDataUpdate', + context: context, + ); + } + + /// Called when the page data is updated. + void onPageData() {} + + /// The name of this page. + String get name => runtimeType.toString(); + + /// Get the deep link for this page. + /// Returns null if [StandardPageFactory.linkGenerator] is not defined for this page. + String? get link => + _factory.linkGenerator != null ? _factory.linkGenerator!(pageData) : null; + + RouteObserver>? _routeObserver; + + bool _ready = false; + + bool _firstActive = true; + bool _active = false; + + /// A flag indicating whether this page is at the top of the history, i.e., whether it's the currently user-interacted page. + bool get active => _active && mounted; + + @protected + AnalyticsEvent? get analyticsSingletonEvent => null; + + @override + @mustCallSuper + void initState() { + super.initState(); + _pageData = widget.pageData; + } + + @override + @mustCallSuper + void didChangeDependencies() { + super.didChangeDependencies(); + + _ready = true; + + _routeObserver ??= context.read>>(); + _routeObserver?.subscribe(this, ModalRoute.of(context)!); + + if (_doPageDataChangedOnReady) { + _doPageDataChangedOnReady = false; + scheduleFunction(_onPageDataChanged); + } + } + + @override + @mustCallSuper + void dispose() { + _routeObserver?.unsubscribe(this); + _routeObserver = null; + + super.dispose(); + } + + @override + @mustCallSuper + void didPopNext() { + _updateActiveStatus(); + } + + @override + @mustCallSuper + void didPush() { + _updateActiveStatus(); + } + + @override + @mustCallSuper + void didPop() { + _updateActiveStatus(); + } + + @override + @mustCallSuper + void didPushNext() { + bool tIgnore = false; + + // We aren't actually popping. + // This is a hack to get access to the most + // recent Route object... + if (mounted) { + Navigator.of(context).popUntil((route) { + if (route is ModalRoute && + !route.opaque && + route.settings is! StandardCustomPage) { + tIgnore = true; + } + + return true; + }); + } + + if (!tIgnore) { + _updateActiveStatus(); + } + } + + void _updateActiveStatus([bool? forcedStatus]) { + // ignore: todo + // TODO: Go up the entire tree to support multiple Navigators. + // We only want to be active if our [Navigator] is also actually in the front. + final tIsCurrentlyActive = _active; + _active = mounted && + (forcedStatus ?? (ModalRoute.of(context)?.isCurrent ?? false)); + + if (_active != tIsCurrentlyActive) { + if (_active) { + _updateRouteAnalyticsContext(); + onActive(_firstActive); + + if (_firstActive) { + _firstActive = false; + } + } else { + onInactive(); + } + } + } + + StandardRouterDelegate? _delegate; + Map>>? _pageChildInstances; + StandardPageWithResultFactory, + Object?, Object?>? _firstPageFactory; + + @protected + @mustCallSuper + void onActive(bool first) { + _delegate = Router.of(context).routerDelegate as StandardRouterDelegate?; + + _pageChildInstances = _delegate?._pageChildInstances; + + final Map>? tStandardPagesMap = + _delegate?._standardPagesMap; + + if (first && _pageChildInstances != null && tStandardPagesMap != null) { + if (tStandardPagesMap.containsKey(_factory.pageType) && + tStandardPagesMap[_factory.pageType]!.isNotEmpty) { + // Create an instance of the first child element to be displayed by default. + var tStandardPageInterface = + ModalRoute.of(context)?.settings as StandardPageInterface; + + final Type tChildPageType; + if (_delegate?._targetFirstChildPage != null) { + tChildPageType = _delegate!._targetFirstChildPage!.pageType; + } else { + tChildPageType = tStandardPagesMap[_factory.pageType]!.first; + } + + _delegate?._standardPageInterfaceToType[tStandardPageInterface]! + .add(tChildPageType); + + _firstPageFactory = _delegate?._addChildFirstPage( + tChildPageType, + tStandardPageInterface, + ); + } + } + } + + @protected + void onInactive() {} + + @protected + void onRefocus() { + final tScrollController = PrimaryScrollController.of(context); + + if (tScrollController.hasClients == true) { + for (var i in tScrollController.positions.where((e) => e.hasPixels)) { + i.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOut, + ); + } + } + } + + @override + @mustCallSuper + Widget build(BuildContext context) { + Widget tChild = Builder(builder: buildPage); + + final tPlugins = context + .read() + .getPluginsOfType() + .where((v) => v.initialized && !v.disposed); + + for (var i in tPlugins) { + tChild = _SingleChildPluginBuilder( + plugin: i, + child: tChild, + ); + } + + final tAnalyticsEvent = analyticsSingletonEvent; + + if (tAnalyticsEvent != null) { + tChild = AnalyticsSingletonEventWidget( + event: tAnalyticsEvent, + child: tChild, + ); + } + + tChild = AnalyticsContextProvider( + analyticsContext: _generateAnalyticsContext(), + child: tChild, + ); + + tChild = Provider.value( + value: this, + child: Provider>.value( + value: this, + child: tChild, + ), + ); + + if (this is StandardPage) { + tChild = Provider.value( + value: this as StandardPage, + child: Provider>.value( + value: this as StandardPage, + child: tChild, + ), + ); + } + + // Build Child Navigator + if (_firstPageFactory != null) { + // Create a Navigator by referencing the parent page instance when _firstPageFactory exists + final tParentPageInstance = + ModalRoute.of(context)?.settings as StandardPageInterface; + + if (_pageChildInstances![tParentPageInstance] != null && + // Countermeasure for an error if the Navigator is empty when + // proceeding from a page with child to a page without child + _pageChildInstances![tParentPageInstance]!.isNotEmpty) { + _navigator = Navigator( + key: _childNavigatorKey, + pages: _pageChildInstances![tParentPageInstance]!, + onPopPage: (route, result) { + if (_delegate?.willPopPage != null) { + if (_delegate!.willPopPage!(route, result)) { + // We return false here because while a _pop_ was handled elsewhere, + // it was not _this_ route whose pop is succeeding. + return false; + } + } + + var tCurrentPage = + _delegate?._pageChildInstances[tParentPageInstance]?.lastOrNull; + if (tCurrentPage != null) { + _delegate?._removePageChildInstance( + tParentPageInstance, tCurrentPage); + } + + if (route.settings is StandardPageInterface) { + return _delegate?.removeRoute(route, result) ?? false; + } + + return false; + }, + ); + + _delegate?._pageChildInstancesUpdater[tParentPageInstance] = () { + setState(() {}); + }; + } + } + + return tChild; + } + + @protected + Widget buildPage(BuildContext context); +} + +class _SingleChildPluginBuilder extends StatelessWidget { + final StandardPagePluginMixin plugin; + final Widget child; + + const _SingleChildPluginBuilder({ + Key? key, + required this.plugin, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) => plugin.buildPage(context, child); +} + +/// {@macro patapata_widgets.StandardPageWithResult} +abstract class StandardPage + extends StandardPageWithResult {} + +/// A data class to be specified when implementing Patapata's [Router] using [StandardRouterDelegate]. +class StandardRouteData { + /// The factory class for [StandardPage]. + final StandardPageWithResultFactory? factory; + + /// The page data to be passed to [StandardPage]. + final Object? pageData; + + /// Create a StandardRouteData + StandardRouteData({ + required this.factory, + required this.pageData, + }); +} + +/// A class that implements [RouteInformationParser] necessary for Patapata's [Router]. +class StandardRouteInformationParser + extends RouteInformationParser { + /// A handle to the location of a widget in the widget tree. + final BuildContext context; + + /// Delegate for the standard router. + final StandardRouterDelegate routerDelegate; + + /// Create a StandardRouteInformationParser + StandardRouteInformationParser({ + required this.context, + required this.routerDelegate, + }); + + @override + Future parseRouteInformation( + RouteInformation routeInformation) async { + final tApp = context.read(); + final tPlugins = tApp.getPluginsOfType(); + + for (var i in tPlugins) { + final tParsed = await i.parseRouteInformation(routeInformation); + + if (tParsed != null) { + return SynchronousFuture(tParsed); + } + } + + for (var i in tPlugins) { + final tTransformed = await i.transformRouteInformation(routeInformation); + + if (tTransformed != null) { + routeInformation = tTransformed; + } + } + + final tLocation = routeInformation.uri; + final tStandardAppPlugin = tApp.getPlugin(); + + if (tStandardAppPlugin != null) { + for (var i in tStandardAppPlugin._linkHandlers.values) { + try { + if (i(tLocation)) { + // Handled. Return early. + return SynchronousFuture(StandardRouteData( + factory: null, + pageData: null, + )); + } + } catch (e, stackTrace) { + _logger.severe('Error while handling link', e, stackTrace); + } + } + } + + final tRouteData = routerDelegate._getStandardRouteDataForPath(tLocation) ?? + StandardRouteData( + factory: null, + pageData: null, + ); + + return SynchronousFuture(tRouteData); + } + + @override + RouteInformation? restoreRouteInformation(StandardRouteData configuration) { + final tStringLocation = + configuration.factory?.generateLink(configuration.pageData); + + if (tStringLocation != null) { + final tLocation = Uri.tryParse(tStringLocation); + + if (tLocation != null) { + return RouteInformation( + uri: tLocation, + ); + } + } + + return null; + } +} + +/// A class that implements [RouterDelegate] required for Patapata's [Router]. +class StandardRouterDelegate extends RouterDelegate + with ChangeNotifier, PopNavigatorRouterDelegateMixin { + final _navigatorKey = GlobalKey( + debugLabel: 'StandardRouterDelegate:navigatorKey'); + final _multiProviderKey = + GlobalKey(debugLabel: 'StandardRouterDelegate:multiProviderKey'); + + /// Navigator observer that notifies RouteAware of changes in route state. + final RouteObserver> routeObserver = + RouteObserver>(); + + /// A handle to the location of a widget in the widget tree. + final BuildContext context; + List _pageInstances = []; + final Map> _pageChildInstances = {}; + + /// Wrap the entire Patapata Navigator-related application, + /// enabling the use of screen transition-related functionalities through a function. + Widget Function(BuildContext context, Widget? child)? routableBuilder; + + /// A function called when the app goes back to the previous page. + bool Function(Route route, dynamic result)? willPopPage; + final Map> _standardPageInterfaceToType = + {}; + + final _factoryTypeMap = {}; + final _pageInstanceToTypeMap = {}; + final _pageInstanceToRouteData = {}; + final _pageInstanceCompleterMap = {}; + + bool _initialRouteProcessed = false; + + bool _startupSequenceProcessed = false; + + final Map> _standardPagesMap = {}; + + final Map _pageChildInstancesUpdater = + {}; + + StandardPageWithResultFactory, + Object?, Object?>? _targetFirstChildPage; + + /// Create a StandardRouterDelegate + StandardRouterDelegate({ + required this.context, + required List pageFactories, + this.routableBuilder, + this.willPopPage, + }) : assert(pageFactories.isNotEmpty) { + _updatePageFactories(pageFactories); + } + + void _updatePageFactories(List pageFactories) { + // Remove the deleted page from the _factoryTypeMap Map variable during hot reloading + _factoryTypeMap.clear(); + _standardPagesMap.clear(); + + for (var tFactory in pageFactories) { + tFactory._delegate = this; + _factoryTypeMap[tFactory.pageType] = tFactory; + // Create Standard Navigator Map + if (tFactory.parentPageType == null && + !_standardPagesMap.containsKey(tFactory.pageType)) { + _standardPagesMap[tFactory.pageType] = []; + } else if (tFactory.parentPageType != null) { + if (!_standardPagesMap.containsKey(tFactory.parentPageType)) { + _standardPagesMap[tFactory.parentPageType!] = []; + } + _standardPagesMap[tFactory.parentPageType]!.add(tFactory.pageType); + } + } + + if (_pageInstances.isNotEmpty) { + for (var i in _pageInstanceToTypeMap.values) { + if (!_factoryTypeMap.containsKey(i)) { + // The page has been deleted. + // Wipe out everything and start the app over. + _pageInstances = []; + _pageChildInstances.clear(); + _pageInstanceToTypeMap.clear(); + _pageInstanceToRouteData.clear(); + for (var i in _pageInstanceCompleterMap.values) { + if (!i.isCompleted) { + // Basically this only happens during development + // so we will not have an official error code or class. + i.completeError('Page deleted'); + } + } + _pageInstanceCompleterMap.clear(); + break; + } + } + } + + if (_pageInstances.isEmpty) { + StandardPageWithResultFactory, + Object?, Object?> tStandardPage; + if (pageFactories.first.parentPageType == null) { + tStandardPage = pageFactories.first; + } else { + // If the first page of pageFactories has a parentType set, the parent page will be added first. + var tResult = pageFactories.where((element) => + element.pageType == pageFactories.first.parentPageType); + tStandardPage = tResult.first; + } + + final tPageData = tStandardPage.pageDataWhenNull?.call(); + + final tPage = _initializePage( + tStandardPage, + tStandardPage.getPageKey(tPageData), + tPageData, + ); + + _pageInstanceToTypeMap[tPage] = pageFactories.first.pageType; + _pageInstanceToRouteData[tPage] = + StandardRouteData(factory: pageFactories.first, pageData: null); + _pageInstanceCompleterMap[tPage] = + pageFactories.first._createResultCompleter(); + _pageInstances.add(tPage); + } + } + + Page _initializePage(StandardPageWithResultFactory factory, LocalKey pageKey, + Object? pageData) { + final tPage = factory._createPage(pageKey, pageData); + + if (factory.parentPageType == null) { + _pageChildInstances[tPage] = []; + if (_standardPageInterfaceToType[tPage] == null) { + _standardPageInterfaceToType[tPage] = []; + } + _standardPageInterfaceToType[tPage]?.add(factory.pageType); + } + + return tPage; + } + + StandardPageWithResultFactory _getFactory< + T extends StandardPageWithResult, + R extends Object?, + E extends Object?>() { + final tFactory = _getFactoryFromPageType(T); + + assert(tFactory.dataType == R); + + return tFactory as StandardPageWithResultFactory; + } + + StandardPageWithResultFactory _getFactoryFromPageType(Type type) { + final tFactory = _factoryTypeMap[type]; + + assert(tFactory != null); + + return tFactory!; + } + + /// {@template patapata_widgets.StandardRouteDelegate.pageInstances} + /// The current [Page] history. + /// {@endtemplate} + List> get pageInstances => _pageInstances.toList(); + + /// {@template patapata_widgets.StandardRouteDelegate.getPageFactory} + /// Get the factory class [StandardPageWithResultFactory] of [StandardPageWithResult]. + /// [T] is the type of the destination page. [R] is the type of page data. [E] is the data type of the value that the page returns. + /// {@endtemplate} + StandardPageWithResultFactory getPageFactory< + T extends StandardPageWithResult, + R extends Object?, + E extends Object?>() => + _getFactory(); + + /// {@template patapata_widgets.StandardRouteDelegate.goWithResult} + /// Navigate to the [StandardPageWithResult] of type [T] that returns a value, with the option to pass [pageData] during the navigation. + /// [T] is the type of the destination page. [R] is the type of page data. [E] is the data type of the value that the page returns. + /// [navigationMode] is optional and represents the mode of [StandardPageNavigationMode] to use during navigation. + /// {@endtemplate} + Future goWithResult, + R extends Object?, E extends Object?>(R pageData, + [StandardPageNavigationMode? navigationMode]) { + return _goWithFactory(_getFactory(), pageData, navigationMode); + } + + /// {@template patapata_widgets.StandardRouteDelegate.go} + /// Navigate to the [StandardPage] of type [T] with the option to pass [pageData] + /// during navigation. + /// [T] represents the type of the destination page, and [R] signifies the type of page data. + /// {@endtemplate} + Future go, R extends Object?>(R pageData, + [StandardPageNavigationMode? navigationMode]) { + return goWithResult(pageData, navigationMode); + } + + /// {@template patapata_widgets.StandardRouteDelegate.goErrorPage} + /// Navigate to the error page with the option to pass an error log information [record]. + /// [record] represents the error log information of type [ReportRecord] to pass, and [navigationMode] signifies the optional mode of [StandardPageNavigationMode] to use during navigation. + /// {@endtemplate} + void goErrorPage(ReportRecord record, + [StandardPageNavigationMode? navigationMode]) { + final tFactory = _factoryTypeMap.values + .whereType() + .firstOrNull; + + if (tFactory != null) { + _goWithFactory(tFactory, record, navigationMode); + } + } + + /// {@template patapata_widgets.StandardRouteDelegate.routeWithConfiguration} + /// Takes `StandardRouteData` data and performs page navigation. + /// This function is used when a reference to [context] is not available, + /// for example, when navigating from a plugin. + /// [configuration] represents the page data to be passed to [goWithResult], + /// [navigationMode] is an optional mode of [StandardPageNavigationMode] to use during navigation. + /// {@endtemplate} + void routeWithConfiguration(StandardRouteData configuration, + [StandardPageNavigationMode? navigationMode]) { + configuration.factory?.goWithResult(configuration.pageData, navigationMode); + } + + @override + Widget build(BuildContext context) { + Widget tChild = Navigator( + key: _navigatorKey, + restorationScopeId: 'StandardAppNavigator', + observers: [ + ...context.read().navigatorObservers, + routeObserver, + ], + pages: _pageInstances, + onPopPage: _onPopPage, + ); + + if (routableBuilder != null) { + tChild = Builder( + builder: + ((child) => (context) => routableBuilder!(context, child))(tChild), + ); + } + + return MultiProvider( + key: _multiProviderKey, + providers: [ + Provider>>.value( + value: routeObserver, + ), + if (getApp().startupSequence != null) + Provider( + lazy: false, + create: (context) { + final tStartupSequence = getApp().startupSequence!; + if (!_startupSequenceProcessed) { + _startupSequenceProcessed = true; + tStartupSequence.resetMachine(); + } + return tStartupSequence; + }, + ), + ], + child: tChild, + ); + } + + void _updatePages() { + _pageInstances = _pageInstances.toList(); + final tPageChildInstances = _pageChildInstances; + for (var entry in tPageChildInstances.entries) { + _pageChildInstances[entry.key] = _pageChildInstances[entry.key]!.toList(); + } + notifyListeners(); + } + + bool _onPopPage(Route route, dynamic result) { + if (willPopPage != null) { + if (willPopPage!(route, result)) { + // We return false here because while a _pop_ was handled elsewhere, + // it was not _this_ route whose pop is succeeding. + return false; + } + } + + if (route.settings is StandardPageInterface) { + // Remove child navigator page from pageChildInstances only when swiping back + var tParentType = _pageInstanceToTypeMap[route.settings]; + if (tParentType != null) { + final tParentPageInstance = _getStandardPageInterface(tParentType); + + if (tParentPageInstance != null) { + if (_pageChildInstances.containsKey(tParentPageInstance)) { + _pageChildInstances[tParentPageInstance]!.clear(); + } + + _pageChildInstances.remove(tParentPageInstance); + _standardPageInterfaceToType.remove(tParentPageInstance); + _pageChildInstancesUpdater.remove(tParentPageInstance); + } + } + + return removeRoute(route, result); + } + + return false; + } + + /// {@template patapata_widgets.StandardRouteDelegate.removeRoute} + /// Removes the provided [route] from the navigator and returns true if + /// it is successfully removed, or false if not found. + /// If the route is removed successfully, it will trigger [Route.didPop]. + /// [route] represents the removed [Route], and [result] signifies the result passed as an argument to [Route.didPop]. + /// {@endtemplate} + bool removeRoute(Route route, dynamic result) { + _pageInstances.remove(route.settings); + _pageInstanceToTypeMap.remove(route.settings); + _pageInstanceToRouteData.remove(route.settings); + + if (route.settings is StandardPageInterface) { + final tState = (route.settings as StandardPageInterface) + .standardPageKey + .currentState; + final tCompleter = _pageInstanceCompleterMap.remove(route.settings); + + if (tCompleter != null) { + tState?._completeResult(tCompleter, result); + } + + tState?._updateActiveStatus(false); + } + + _updatePages(); + + if (route.didPop(result)) { + return true; + } + + return false; + } + + /// {@template patapata_widgets.StandardRouteDelegate.processInitialRoute} + /// Selects the initial page that the application should display and navigates to that page. + /// If this initialization has already been performed, it does nothing. + /// + /// The priority of what page is shown is as follows: + /// First, if there is a plugin in the [App] that returns data from getInitialRouteData, that data is used to navigate. + /// Next, if there is a link that opened this application, that link is used. + /// If neither of these conditions is met, a link with an empty string in the [StandardMaterialApp.pages] array using [StandardPageFactory.new]'s links is searched for and navigated to. + /// If no page can be found, an assertion error is thrown. + /// {@endtemplate} + void processInitialRoute() async { + if (_initialRouteProcessed) { + return; + } + + _initialRouteProcessed = true; + + final tPlugins = _navigatorKey.currentContext + ?.read() + .getPluginsOfType(); + + if (tPlugins != null) { + for (var i in tPlugins) { + final tRouteData = await i.getInitialRouteData(); + + if (tRouteData != null) { + routeWithConfiguration(tRouteData); + + return; + } + } + } + + final tInitialRouteData = + _initialRouteData ?? _getStandardRouteDataForPath(Uri(path: '')); + _initialRouteData = null; + + assert(tInitialRouteData != null); + + routeWithConfiguration(tInitialRouteData!); + } + + StandardRouteData? _initialRouteData; + + @override + Future setNewRoutePath(StandardRouteData configuration) { + if (!_initialRouteProcessed) { + _initialRouteData = configuration; + + return SynchronousFuture(null); + } + + routeWithConfiguration(configuration); + + return SynchronousFuture(null); + } + + @override + StandardRouteData? get currentConfiguration => _pageInstances.isEmpty + ? null + : _pageInstanceToRouteData[_pageInstances.last]; + + @override + GlobalKey? get navigatorKey => _navigatorKey; + + /// Retrieves the current `Navigator`. + NavigatorState get navigator { + assert(_navigatorKey.currentState != null, + 'Navigator does not exist yet. Wait for the first build to finish before using [navigator].'); + + return _navigatorKey.currentState!; + } + + /// Retrieves the `BuildContext` of the Navigator. + BuildContext get navigatorContext { + assert(_navigatorKey.currentContext != null, + 'Navigator does not exist yet. Wait for the first build to finish before using [navigatorContext].'); + + return _navigatorKey.currentContext!; + } + + StandardRouteData? _getStandardRouteDataForPath(Uri location) { + for (var i in _factoryTypeMap.values) { + for (var j in i._links.entries) { + final tMatch = j.key.firstMatch(location.path); + + if (tMatch != null) { + try { + return StandardRouteData( + factory: i, + pageData: j.value(tMatch, location), + ); + } catch (e, stackTrace) { + _logger.info('Exception during links callback', e, stackTrace); + // But ignore. + } + } + } + } + + return null; + } + + Future _goWithFactory( + StandardPageWithResultFactory factory, + Object? pageData, + StandardPageNavigationMode? navigationMode, + ) { + StandardPageWithResultFactory tFactory = factory; + + var tCurrentPage = _pageInstances.lastOrNull; + StandardPageWithResultFactory, + Object?, Object?>? tParentPageFactory; + + if (factory.parentPageType != null) { + final tParentPageInstance = + _getStandardPageInterface(factory.parentPageType!); + if (tParentPageInstance == null) { + tParentPageFactory = _getFactoryFromPageType(factory.parentPageType!); + _targetFirstChildPage = factory; + } else { + _targetFirstChildPage = null; + } + } else { + _targetFirstChildPage = null; + } + + tFactory = tParentPageFactory ?? factory; + + void fCleanupPage(StandardPageInterface page) { + final tCompleter = _pageInstanceCompleterMap.remove(page); + final tState = page.standardPageKey.currentState; + + if (tCompleter != null) { + tState?._completeResult(tCompleter, null); + } + + tState?._updateActiveStatus(false); + } + + void fRemoveLastPage() { + final tPage = _pageInstances.removeLast(); + _pageInstanceToTypeMap.remove(tPage); + _pageInstanceToRouteData.remove(tPage); + fCleanupPage(tPage as StandardPageInterface); + } + + if (tCurrentPage != null) { + assert(_pageInstanceToTypeMap.containsKey(tCurrentPage)); + + var tCurrentFactory = + _getFactoryFromPageType(_pageInstanceToTypeMap[tCurrentPage]!); + + if (!tCurrentFactory.keepHistory) { + fRemoveLastPage(); + } + + if (tFactory.group != null) { + while (true) { + tCurrentPage = _pageInstances.lastOrNull; + + if (tCurrentPage == null) { + break; + } + + assert(_pageInstanceToTypeMap.containsKey(tCurrentPage)); + + tCurrentFactory = + _getFactoryFromPageType(_pageInstanceToTypeMap[tCurrentPage]!); + + if (tCurrentFactory.group != null) { + break; + } + } + + if (tCurrentPage != null && tCurrentFactory.group != tFactory.group) { + // Different group than the current one. + // Remove all history. + var tReversedPageInstances = _pageInstances.reversed.toList(); + + _pageInstanceToTypeMap.clear(); + _pageInstanceToRouteData.clear(); + _pageInstances.clear(); + for (var tPageInstance in tReversedPageInstances) { + fCleanupPage(tPageInstance as StandardPageInterface); + } + } + } + } + + final tNavigationMode = navigationMode ?? tFactory.navigationMode; + + if (tNavigationMode == StandardPageNavigationMode.removeAll) { + // Remove all history. + var tReversedPageInstances = _pageInstances.reversed.toList(); + + _pageInstanceToTypeMap.clear(); + _pageInstanceToRouteData.clear(); + _pageInstances.clear(); + for (var tPageInstance in tReversedPageInstances) { + fCleanupPage(tPageInstance as StandardPageInterface); + } + } else if (tNavigationMode == StandardPageNavigationMode.replace) { + fRemoveLastPage(); + } + + final tPageKey = tFactory.getPageKey(pageData); + + void fCheckGroupRoot() { + if (!tFactory.groupRoot) { + // Search to see if a group root exists and add it. + final tGroup = tFactory.group; + + for (var i in _factoryTypeMap.entries) { + final tGroupRootFactory = i.value; + + if (tGroupRootFactory.group == tGroup && + tGroupRootFactory.groupRoot) { + // Found one. See if it's already in the history. + if (!_pageInstanceToTypeMap.containsValue(i.key)) { + // There isn't. So add it. + final tGroupRootPageData = + tGroupRootFactory.pageDataWhenNull?.call(); + final tGroupRootPageKey = + tGroupRootFactory.getPageKey(tGroupRootPageData); + final tGroupRootPage = _initializePage( + tGroupRootFactory, tGroupRootPageKey, tGroupRootPageData); + + _pageInstanceToTypeMap[tGroupRootPage] = + tGroupRootFactory.pageType; + _pageInstanceToRouteData[tGroupRootPage] = StandardRouteData( + factory: tGroupRootFactory, + pageData: tGroupRootPageData, + ); + _pageInstanceCompleterMap[tGroupRootPage] = + tGroupRootFactory._createResultCompleter(); + _pageInstances.insert(0, tGroupRootPage); + } + } + } + } + } + + // First check to see if we already have this page's representation + // in the history stack. If we do, modify the history stack and use the old instance. + for (var i = 0, il = _pageInstances.length; i < il; i++) { + if (_pageInstances[i].key == tPageKey) { + switch (tNavigationMode) { + case StandardPageNavigationMode.moveToTop: + case StandardPageNavigationMode.replace: + if (i == il - 1) { + break; + } + + final tLastPage = (_pageInstances.last as StandardPageInterface?) + ?.standardPageKey + .currentState; + tLastPage?._updateActiveStatus(false); + + final tPageToMove = _pageInstances[i]; + + // Shift all instances from this point to the left by one. + // Ignore the last index as we just replace it + for (var j = i; j < il - 1; j++) { + _pageInstances[j] = _pageInstances[j + 1]; + } + + _pageInstances[il - 1] = tPageToMove; + + break; + case StandardPageNavigationMode.removeAbove: + for (var j = il - 1; j > i; j--) { + fRemoveLastPage(); + } + + break; + default: + break; + } + + final tLastPageInstance = _pageInstances.last; + final tLastPage = (tLastPageInstance as StandardPageInterface?) + ?.standardPageKey + .currentState; + + if (tLastPage != null) { + if (tLastPage.pageData != pageData) { + tLastPage.pageData = pageData; + } + + if (tLastPage.active) { + tLastPage.onRefocus(); + } else { + final tRoute = ModalRoute.of(tLastPage.context); + + if (tRoute != null) { + tLastPage.context.read().routeViewEvent( + tRoute, + navigationType: AnalyticsNavigationType.push, + ); + } + tLastPage._updateActiveStatus(true); + } + } + + fCheckGroupRoot(); + + final tCompleter = _pageInstanceCompleterMap[tLastPageInstance]!; + + assert( + tCompleter is Completer, + 'Same PageKey used for pages that have different return types.' + 'This is not allowed. PageKey: $tPageKey', + ); + + final tFuture = (tCompleter as Completer).future; + + _updatePages(); + + return tFuture; + } + } + + final tPage = _initializePage(tFactory, tPageKey, pageData); + + _pageInstanceToTypeMap[tPage] = tFactory.pageType; + _pageInstanceToRouteData[tPage] = StandardRouteData( + factory: tFactory, + pageData: pageData, + ); + final tResultCompleter = _pageInstanceCompleterMap[tPage] = + tFactory._createResultCompleter() as Completer; + + if (factory.parentPageType == null || + (factory.parentPageType != null && tParentPageFactory != null)) { + _pageInstances.add(tPage); + } else { + var tParentStandardPageInterface = + _getStandardPageInterface(factory.parentPageType!); + + if (tParentStandardPageInterface != null) { + _addPageChildInstance(tParentStandardPageInterface, tPage); + + if (_pageChildInstancesUpdater[tParentStandardPageInterface] != null) { + _pageChildInstancesUpdater[tParentStandardPageInterface]!(); + } + } + } + + fCheckGroupRoot(); + + _updatePages(); + + return tResultCompleter.future; + } + + void _addPageChildInstance(Page parentPage, Page page) { + _pageChildInstances[parentPage]?.add(page); + } + + void _removePageChildInstance(Page parentPage, Page page) { + _pageChildInstances[parentPage]?.remove(page); + } + + StandardPageInterface? _getStandardPageInterface(Type pageType) { + // Check the page Type in _standardPageInterfaceToType + // _addPageChildInstance under which parent entity the tPage + // generated by _initializePage isdetermine whether to add + final tStandardPageInterfaceToType = _standardPageInterfaceToType.entries + .firstWhereOrNull((standardPageInterfaceToType) { + final tResult = standardPageInterfaceToType.value + .where((element) => element == pageType); + return tResult.isNotEmpty; + }); + + if (tStandardPageInterfaceToType == null) { + return null; + } + + return tStandardPageInterfaceToType.key; + } + + StandardPageWithResultFactory _addChildFirstPage( + Type pageType, + StandardPageInterface parentPageInstance, + ) { + final tFactory = _getFactoryFromPageType(pageType); + final tPageData = tFactory.pageDataWhenNull?.call(); + final tPageKey = tFactory.getPageKey(tPageData); + + final tPage = _initializePage(tFactory, tPageKey, tPageData); + + _pageInstanceToTypeMap[tPage] = tFactory.pageType; + _pageInstanceToRouteData[tPage] = StandardRouteData( + factory: tFactory, + pageData: tPageData, + ); + _pageInstanceCompleterMap[tPage] = tFactory._createResultCompleter(); + + // If the page to be added belongs to the parent Navigator, add it to _pageChildInstances + if (_pageChildInstances.keys.contains(parentPageInstance)) { + _addPageChildInstance(parentPageInstance, tPage); + } + + return tFactory; + } +} diff --git a/packages/patapata_core/lib/src/widgets/standard_page_widget.dart b/packages/patapata_core/lib/src/widgets/standard_page_widget.dart new file mode 100644 index 0000000..e4b1373 --- /dev/null +++ b/packages/patapata_core/lib/src/widgets/standard_page_widget.dart @@ -0,0 +1,78 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +part of "standard_app.dart"; + +/// A back button specified at the top of the AppBar of a child page, +/// which includes multiple navigators, in both Material and Cupertino Design. +/// +class StandardPageBackButton extends StatelessWidget { + const StandardPageBackButton({ + super.key, + this.color, + this.previousPageTitle, + this.onPressed, + }); + + /// The color to be used for the icon. + /// See [BackButton.color] of [BackButton] for more details. + final Color? color; + + /// The previousPageTitle to be used for the title. + /// See [CupertinoNavigationBarBackButton.previousPageTitle] of [CupertinoNavigationBarBackButton] for more details. + /// Defaults to [CupertinoTheme]'s `primaryColor` if null. + final String? previousPageTitle; + + /// A callback that can be optionally defined and called by the user before popping when the button is tapped. + final VoidCallback? onPressed; + + void _findClosestNavigatorStateCanPop(BuildContext context) { + // Find closest NavigatorState and pop if canPop + context.visitAncestorElements((element) { + if (element is StatefulElement && element.state is NavigatorState) { + NavigatorState tNavigatorState = element.state as NavigatorState; + if (tNavigatorState.canPop()) { + tNavigatorState.pop(); + return false; + } else if (element.widget.key == _childNavigatorKey) { + // Delete the page that exists in pageChildInstances if it could not be popped + final tPage = element.pageInstances.last; + element.pageChildInstances[tPage]!.clear(); + } + } + return true; + }); + } + + @override + Widget build(BuildContext context) { + final StandardAppType tType = context.watch(); + + if (tType == StandardAppType.material) { + assert(debugCheckHasMaterialLocalizations(context)); + return IconButton( + icon: const BackButtonIcon(), + color: color, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () { + onPressed?.call(); + _findClosestNavigatorStateCanPop(context); + }, + ); + } else if (tType == StandardAppType.cupertino) { + assert(debugCheckHasCupertinoLocalizations(context)); + return CupertinoNavigationBarBackButton( + color: color, + previousPageTitle: previousPageTitle, + onPressed: () { + onPressed?.call(); + _findClosestNavigatorStateCanPop(context); + }, + ); + } else { + return const SizedBox.shrink(); + } + } +} diff --git a/packages/patapata_core/lib/web/patapata_web_plugin.dart b/packages/patapata_core/lib/web/patapata_web_plugin.dart new file mode 100644 index 0000000..ac771f2 --- /dev/null +++ b/packages/patapata_core/lib/web/patapata_web_plugin.dart @@ -0,0 +1,11 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +// Interface for Web Plugin +abstract class PatapataPlugin { + String get patapataName; + void patapataEnable() {} + void patapataDisable() {} +} diff --git a/packages/patapata_core/lib/web/web_local_config.dart b/packages/patapata_core/lib/web/web_local_config.dart new file mode 100644 index 0000000..07e8ba7 --- /dev/null +++ b/packages/patapata_core/lib/web/web_local_config.dart @@ -0,0 +1,111 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:patapata_core/web/patapata_web_plugin.dart'; + +import 'dart:html' as html; + +class WebLocalConfig extends PatapataPlugin { + WebLocalConfig(this.registrar); + final Registrar registrar; + + @override + String patapataName = "dev.patapata.native_local_config"; + + @override + void patapataDisable() { + channel?.setMethodCallHandler(null); + } + + MethodChannel? channel; + + @override + void patapataEnable() { + channel = MethodChannel( + patapataName, + const StandardMethodCodec(), + registrar, + ); + + channel?.setMethodCallHandler(handleMethodCall); + + _sync(); + } + + Future handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'reset': + html.window.localStorage.remove(call.arguments as String); + break; + case 'resetMany': + var tArgs = call.arguments as List; + for (var arg in tArgs) { + html.window.localStorage.remove(arg); + } + break; + case 'resetAll': + html.window.localStorage.clear(); + break; + case 'setBool': + var tArgs = call.arguments as List; + html.window.localStorage[tArgs[0] as String] = + 'b${(tArgs[1] as bool) ? '1' : '0'}'; + break; + case 'setInt': + var tArgs = call.arguments as List; + html.window.localStorage[tArgs[0] as String] = 'i${tArgs[1]}'; + break; + case 'setDouble': + var tArgs = call.arguments as List; + html.window.localStorage[tArgs[0] as String] = 'd${tArgs[1]}'; + break; + case 'setString': + var tArgs = call.arguments as List; + html.window.localStorage[tArgs[0] as String] = 's${tArgs[1]}'; + break; + case 'setMany': + var tArgs = (call.arguments as List).cast>(); + for (var arg in tArgs) { + html.window.localStorage[arg[0]] = arg[1]; + } + break; + default: + break; + } + + _sync(); + } + + void _sync() { + channel?.invokeMethod('syncAll', html.window.localStorage.map((key, value) { + Object tValue; + + switch (value[0]) { + case 'b': + tValue = value.substring(1) == '1'; + + break; + case 'i': + tValue = int.tryParse(value.substring(1)) ?? 0; + + break; + case 'd': + tValue = double.tryParse(value.substring(1)) ?? 0.0; + + break; + case 's': + tValue = value.substring(1); + + break; + default: + tValue = ''; + } + + return MapEntry(key, tValue); + })); + } +} diff --git a/packages/patapata_core/lib/web/web_local_config_finder.dart b/packages/patapata_core/lib/web/web_local_config_finder.dart new file mode 100644 index 0000000..98646b2 --- /dev/null +++ b/packages/patapata_core/lib/web/web_local_config_finder.dart @@ -0,0 +1,106 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/services.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +const _methodChannel = MethodChannel('dev.patapata.native_local_config'); + +final _logger = Logger('patapata.NativeLocalConfig'); + +class WebLocalConfigFinder implements LocalConfigFinder { + @override + LocalConfig? getLocalConfig() => WebLocalConfig(); +} + +class WebLocalConfig extends LocalConfig with MemoryLocalConfig { + @override + Future init() async { + _methodChannel.setMethodCallHandler(_onMethodCall); + await super.init(); + } + + @override + void dispose() async { + _methodChannel.setMethodCallHandler(null); + super.dispose(); + } + + Future _onMethodCall(MethodCall call) async { + switch (call.method) { + case 'syncAll': + _onSyncAll((call.arguments as Map).cast()); + break; + case 'error': + _onError((call.arguments as Map).cast()); + break; + default: + break; + } + } + + void _onSyncAll(Map data) { + store.clear(); + store.addAll(data); + notifyListeners(); + } + + void _onError(Map error) { + final tError = NativeThrowable.fromMap(error); + + _logger.severe(tError.message, tError, tError.chain); + } + + @override + Future reset(String key) async { + await super.reset(key); + await _methodChannel.invokeMethod('reset', key); + } + + @override + Future resetAll() async { + await super.resetAll(); + await _methodChannel.invokeMethod('resetAll'); + } + + @override + Future resetMany(List keys) async { + await super.resetMany(keys); + await _methodChannel.invokeMethod('resetMany', keys); + } + + @override + Future setBool(String key, bool value) async { + await _methodChannel.invokeMethod('setBool', [key, value]); + } + + @override + Future setDouble(String key, double value) async { + await _methodChannel.invokeMethod('setDouble', [key, value]); + } + + @override + Future setInt(String key, int value) async { + await _methodChannel.invokeMethod('setInt', [key, value]); + } + + @override + Future setString(String key, String value) async { + await _methodChannel.invokeMethod('setString', [key, value]); + } + + @override + Future setMany(Map objects) async { + await _methodChannel.invokeMethod('setMany', objects); + } + + @override + Future setDefaults(Map defaults) async { + await super.setDefaults(defaults); + } +} + +LocalConfigFinder getLocalConfigFinder() => WebLocalConfigFinder(); diff --git a/packages/patapata_core/macos/.gitignore b/packages/patapata_core/macos/.gitignore new file mode 100644 index 0000000..0c88507 --- /dev/null +++ b/packages/patapata_core/macos/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/packages/patapata_core/macos/Classes/NativeLocalConfig.swift b/packages/patapata_core/macos/Classes/NativeLocalConfig.swift new file mode 100644 index 0000000..655d680 --- /dev/null +++ b/packages/patapata_core/macos/Classes/NativeLocalConfig.swift @@ -0,0 +1,120 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import FlutterMacOS + +class NativeLocalConfig : PatapataPlugin { + fileprivate let mChannel: FlutterMethodChannel + fileprivate var mOnChangeListener: Any? + fileprivate var mOnSizeLimitExceededListener: Any? + fileprivate let mStore = UserDefaults(suiteName: "\(Bundle.main.bundleIdentifier ?? "unknown").dev.patapata.native_local_config") ?? UserDefaults.standard + + init(registrar: FlutterPluginRegistrar) { + mChannel = FlutterMethodChannel(name: "dev.patapata.native_local_config", binaryMessenger: registrar.messenger) + } + + public var patapataName: String = "dev.patapata.native_local_config" + + public func patapataEnable() { + mChannel.setMethodCallHandler(handle) + + mOnChangeListener = NotificationCenter.default.addObserver(forName: UserDefaults.didChangeNotification, object: mStore, queue: OperationQueue.main, using: onChange) + + syncStore() + } + + fileprivate func syncStore() { + let tDict = mStore.dictionaryRepresentation().filter { + switch $0.value { + case is Bool: + return true + case is Int: + return true + case is Double: + return true + case is String: + return true + default: + return false + } + } + + mChannel.invokeMethod("syncAll", arguments: tDict) + } + + fileprivate func onChange(_: Notification) { + syncStore() + } + + fileprivate func onSizeLimitExceeded(notification: Notification) { + // mChannel.invokeMethod("error", arguments: <#T##Any?#>) + // Should we send this? It could happen async from a set command + // It could also happen from something not related to patapata at all... + } + + public func patapataDisable() { + mChannel.setMethodCallHandler(nil) + NotificationCenter.default.removeObserver(mOnChangeListener!, name: UserDefaults.didChangeNotification, object: mStore) + mOnChangeListener = nil + + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "reset": + mStore.removeObject(forKey: call.arguments as! String) + result(nil) + break + case "resetMany": + let tArgs = call.arguments as! Array + + for i in tArgs { + mStore.removeObject(forKey: i) + } + + result(nil) + break + case "resetAll": + for i in mStore.dictionaryRepresentation() { + mStore.removeObject(forKey: i.key) + } + + result(nil) + break + case "setBool": + let tArgs = call.arguments as! Array + mStore.set(tArgs[1] as! Bool, forKey: tArgs[0] as! String) + result(nil) + break + case "setInt": + let tArgs = call.arguments as! Array + mStore.set(tArgs[1] as! Int, forKey: tArgs[0] as! String) + result(nil) + break + case "setDouble": + let tArgs = call.arguments as! Array + mStore.set(tArgs[1] as! Double, forKey: tArgs[0] as! String) + result(nil) + break + case "setString": + let tArgs = call.arguments as! Array + mStore.set(tArgs[1] as! String, forKey: tArgs[0] as! String) + result(nil) + break + case "setMany": + let tArgs = call.arguments as! Dictionary + + for i in tArgs { + mStore.set(i.value, forKey: i.key) + } + + result(nil) + break + default: + result(FlutterMethodNotImplemented) + break + } + } +} diff --git a/packages/patapata_core/macos/Classes/PatapataCorePlugin.swift b/packages/patapata_core/macos/Classes/PatapataCorePlugin.swift new file mode 100644 index 0000000..031cf48 --- /dev/null +++ b/packages/patapata_core/macos/Classes/PatapataCorePlugin.swift @@ -0,0 +1,100 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Cocoa +import FlutterMacOS + +fileprivate var sPlugins = Set() + +public class PatapataCorePlugin: NSObject, FlutterPlugin { + let mRegistrar: FlutterPluginRegistrar + let mChannel: FlutterMethodChannel + + init(registrar: FlutterPluginRegistrar) { + mRegistrar = registrar + mChannel = FlutterMethodChannel(name: "dev.patapata.patapata_core", binaryMessenger: registrar.messenger) + + super.init() + + registrar.addMethodCallDelegate(self, channel: mChannel) + + // Register default plugins. + registrar.registerPatapata(plugin: NativeLocalConfig(registrar: registrar)) + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let _ = PatapataCorePlugin(registrar: registrar) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "enablePlugin": + guard let tName = call.arguments as? String else { + result(FlutterError(code: "PPE000", message: "Invalid plugin name passed to enablePlugin", details: nil)) + + return + } + + enablePlugin(with: tName) + result(nil) + case "disablePlugin": + guard let tName = call.arguments as? String else { + result(FlutterError(code: "PPE000", message: "Invalid plugin name passed to disablePlugin", details: nil)) + + return + } + + disablePlugin(with: tName) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + public func enablePlugin(with pluginName: String) { + for i in sPlugins { + guard i.plugin.patapataName == pluginName && i.registrar.messenger.hash == mRegistrar.messenger.hash else { + continue + } + + i.plugin.patapataEnable(); + } + } + + public func disablePlugin(with pluginName: String) { + for i in sPlugins { + guard i.plugin.patapataName == pluginName && i.registrar.messenger.hash == mRegistrar.messenger.hash else { + continue + } + + i.plugin.patapataDisable() + } + } +} + +fileprivate struct PatapataPluginContainer : Hashable { + let plugin: PatapataPlugin + let registrar: FlutterPluginRegistrar + + static func == (lhs: PatapataPluginContainer, rhs: PatapataPluginContainer) -> Bool { + return lhs.plugin.patapataName == rhs.plugin.patapataName && lhs.registrar.messenger.hash == rhs.registrar.messenger.hash + } + + func hash(into hasher: inout Hasher) { + hasher.combine(plugin.patapataName) + hasher.combine(registrar.messenger.hash) + } +} + + +extension FlutterPluginRegistrar { + public func registerPatapata(plugin: PatapataPlugin) { + sPlugins.insert(PatapataPluginContainer(plugin: plugin, registrar: self)) + } + + public func unregisterPatapata(plugin: PatapataPlugin) { + sPlugins.remove(PatapataPluginContainer(plugin: plugin, registrar: self)) + } +} diff --git a/packages/patapata_core/macos/Classes/PatapataCorePluginBridge.h b/packages/patapata_core/macos/Classes/PatapataCorePluginBridge.h new file mode 100644 index 0000000..bdee0d2 --- /dev/null +++ b/packages/patapata_core/macos/Classes/PatapataCorePluginBridge.h @@ -0,0 +1,11 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface PatapataCorePluginBridge : NSObject +@end diff --git a/packages/patapata_core/macos/Classes/PatapataCorePluginBridge.m b/packages/patapata_core/macos/Classes/PatapataCorePluginBridge.m new file mode 100644 index 0000000..951eb16 --- /dev/null +++ b/packages/patapata_core/macos/Classes/PatapataCorePluginBridge.m @@ -0,0 +1,20 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#import "PatapataCorePluginBridge.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "patapata_core-Swift.h" +#endif + +@implementation PatapataCorePluginBridge ++ (void)registerWithRegistrar:(NSObject*)registrar { + [PatapataCorePlugin registerWithRegistrar:registrar]; +} +@end diff --git a/packages/patapata_core/macos/Classes/PatapataPlugin.swift b/packages/patapata_core/macos/Classes/PatapataPlugin.swift new file mode 100644 index 0000000..c9ca8f6 --- /dev/null +++ b/packages/patapata_core/macos/Classes/PatapataPlugin.swift @@ -0,0 +1,16 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + + +public protocol PatapataPlugin { + var patapataName: String { get } + func patapataEnable() + func patapataDisable() +} + +public extension PatapataPlugin { + func patapataEnable() {} + func patapataDisable() {} +} diff --git a/packages/patapata_core/macos/patapata_core.podspec b/packages/patapata_core/macos/patapata_core.podspec new file mode 100644 index 0000000..de917c8 --- /dev/null +++ b/packages/patapata_core/macos/patapata_core.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint patapata_core.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'patapata_core' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/packages/patapata_core/makefile b/packages/patapata_core/makefile new file mode 100644 index 0000000..8f6138b --- /dev/null +++ b/packages/patapata_core/makefile @@ -0,0 +1,5 @@ +.PHONY: coverage +coverage: + flutter test --coverage --dart-define=IS_TEST=true + genhtml coverage/lcov.info -o coverage/html + open coverage/html/index.html \ No newline at end of file diff --git a/packages/patapata_core/pubspec.yaml b/packages/patapata_core/pubspec.yaml new file mode 100644 index 0000000..4fadd2c --- /dev/null +++ b/packages/patapata_core/pubspec.yaml @@ -0,0 +1,71 @@ +name: patapata_core +description: A collection of best-practices for building applications quickly and reliably. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_core + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +executables: + bootstrap: + +dependencies: + intl: ">=0.17.0 <0.19.0" + timezone: ">=0.9.0 <0.11.0" + + logging: ^1.2.0 + stack_trace: ^1.11.0 + provider: ^6.0.5 + connectivity_plus: ^5.0.0 + visibility_detector: ^0.4.0+2 + collection: ">=1.17.2 <1.19.0" + yaml: ^3.1.1 + package_info_plus: ^4.1.0 + device_info_plus: ^9.0.3 + device_info_plus_platform_interface: ^7.0.0 + flutter_local_notifications: ^15.1.0 + + flutter_web_plugins: + sdk: flutter + + flutter: + sdk: flutter + + flutter_localizations: + sdk: flutter + args: ^2.4.2 + dart_style: ^2.3.2 + xml: ^6.3.0 + yaml_edit: ^2.1.1 + +dev_dependencies: + test: ">=1.20.0" + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + golden_toolkit: ^0.15.0 + mockito: ^5.4.2 + flutter_svg: ^2.0.10+1 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' and Android 'package' identifiers should not ordinarily + # be modified. They are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: dev.patapata.patapata_core + pluginClass: PatapataCorePlugin + ios: + pluginClass: PatapataCorePluginBridge + macos: + pluginClass: PatapataCorePlugin + web: + pluginClass: PatapataCoreWeb + fileName: patapata_core_web.dart diff --git a/packages/patapata_core/test/analytics_test.dart b/packages/patapata_core/test/analytics_test.dart new file mode 100644 index 0000000..da516f8 --- /dev/null +++ b/packages/patapata_core/test/analytics_test.dart @@ -0,0 +1,1933 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:provider/provider.dart'; +import 'package:visibility_detector/visibility_detector.dart'; +import 'utils/patapata_core_test_utils.dart'; + +const String kAnalyticsButton = 'analytics-button'; +const String kAnalyticsHomeScreenButton = 'analytics-home-screen-button'; +const String kAnalyticsSecondScreenButton = 'analytics-second-screen-button'; +const String kAnalyticsSecondRemoveScreenButton = + 'analytics-second-remove-screen-button'; +const String kAnalyticsStandardPageButton = 'analytics-standard-page-button'; +const String kAnalyticsEventButton = 'analytics-event-button'; +const String kAnalyticsRawEventButton = 'analytics-raw-event-button'; +const String kAnalyticsSingletonButton = 'analytics-singleton-button'; +const String kAnalyticsUpdateWidgetButton = 'analytics-update-widget-button'; +const String kAnalyticsNotifyDataChangedButton = + 'analytics-notify-data-changed-button'; +const String kAnalyticsTestFirstPageButton = 'analytics-test-first-page-button'; + +// Analytics Test Environment Configuration Class +class _TestAnalyticsEnviroment extends Environment + with AnalyticsEventFilterEnvironment { + const _TestAnalyticsEnviroment(); + @override + final analyticsEventFilter = + const {}; +} + +// Analytics Test Environment Configuration Class with Filters +class _TestAnalyticsEventFilterEnviroment extends Environment + with AnalyticsEventFilterEnvironment { + _TestAnalyticsEventFilterEnviroment(); + @override + final analyticsEventFilter = + { + // A test filter for treating a specific event as a different RawEvent. + _TestAnalyticsEvent: (AnalyticsEvent event) => + event is _TestAnalyticsEvent ? _TestRawEvent(name: event.name) : event, + }; +} + +// RawEvent for Analytics Testing +class _TestRawEvent extends AnalyticsEvent { + _TestRawEvent({required super.name}); +} + +// AnalyticsEvent for Analytics Testing +class _TestAnalyticsEvent extends AnalyticsEvent { + _TestAnalyticsEvent({required super.name}); +} + +// StandardPage for Analytics Testing +class _TestAnalyticsPage extends StandardPage { + String _interactionContextData = ''; + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView( + children: [ + SizedBox( + child: TextButton( + key: const ValueKey(kAnalyticsStandardPageButton), + onPressed: () { + AnalyticsEvent tAnalyticsEvent = AnalyticsEvent( + name: 'testEvents', + data: { + 'test': 'testData', + }, + context: getApp().analytics.globalContext, + ); + getApp().analytics.rawEvent(tAnalyticsEvent); + + setState(() { + _interactionContextData = + context.read().toString(); + }); + }, + child: const Text('On Tap'), + ), + ), + Text( + "context.read().interactionContextData:$_interactionContextData", + ), + ], + ), + ); + } +} + +class _TestAnalyticsContextProviderPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: AnalyticsContextProvider( + reset: false, + analyticsContext: AnalyticsContext( + { + 'hogehogeName': 'AnalyticsContextParent', + 'hogehogeData': null, + 'hogehgoeLink': 'HogehogeLink', + }, + ), + child: AnalyticsContextProvider( + reset: true, + analyticsContext: AnalyticsContext( + { + 'pageName': 'AnalyticsContextPage', + 'pageData': null, + 'pageLink': 'AnalyticsContextPageLink', + }, + ), + child: const AnalyticsEventWidget( + key: ValueKey(kAnalyticsButton), + name: 'context analytics event', + child: Text('On Tap'), + ), + ), + ), + ); + } +} + +class _TestAnalyticsSingletonEventWidgetPage extends StandardPage { + bool _toggle = false; + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView( + children: [ + const Center( + child: Text('test'), + ), + Column( + children: [ + if (_toggle) + const AnalyticsSingletonEventWidget( + name: 'Event1', + data: {'test': 'testData1'}, + child: Text('Event1'), + ) + else + const AnalyticsSingletonEventWidget( + name: 'Event2', + data: {'test': 'testData2'}, + child: Text('Event2'), + ), + TextButton( + key: const ValueKey(kAnalyticsSingletonButton), + onPressed: () { + setState(() { + _toggle = !_toggle; + }); + }, + child: const Text('Analytics Event'), + ), + ], + ), + ], + ), + ); + } +} + +class _TestAnalyticsImpressionWidgetPage extends StandardPage { + final int _itemCount = 25; + final int _selectedItem = 5; + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView.builder( + itemCount: _itemCount, + itemBuilder: (context, index) { + return SizedBox( + height: 100, + child: index == _selectedItem + ? AnalyticsContextProvider( + analyticsContext: AnalyticsContext({ + 'section': 'Section $index', + }), + child: AnalyticsImpressionWidget( + durationThreshold: Duration.zero, + visibleThreshold: 0.5, + name: 'Analytics Impression', + data: { + 'name': 'Analytics Page Item $index', + }, + child: Text("Impression index : $index"), + batchGenerator: (datas, contexts) { + return { + 'name': datas.map((e) => e['name']).join(','), + 'section': contexts + .map((e) => e.resolve()['section']) + .join(','), + }; + }, + ), + ) + : Text("Impression index : $index"), + ); + }, + ), + ); + } +} + +class _TestAnalyticsImpressionWidgetNoneBatchPage extends StandardPage { + final int _itemCount = 35; + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView.builder( + itemCount: _itemCount, + itemBuilder: (context, index) { + return SizedBox( + height: 100, + child: index % 10 == 0 + ? AnalyticsContextProvider( + analyticsContext: AnalyticsContext({ + 'section': 'Section $index', + }), + child: AnalyticsImpressionWidget( + durationThreshold: Duration.zero, + visibleThreshold: 0.5, + name: 'Analytics Impression', + data: { + 'name': 'Analytics Page Item $index', + }, + child: Text("Impression index : $index"), + ), + ) + : Text("Impression index : $index"), + ); + }, + ), + ); + } +} + +class _TestAnalyticsImpressionDidUpdateWidgetPage extends StandardPage { + final int _itemCount = 25; + final int _selectedItem = 5; + late AnalyticsEvent _analyticsEvent; + + @override + void initState() { + _analyticsEvent = AnalyticsEvent( + name: 'Analytics Impression', + data: { + 'name': 'Analytics Page Item', + }, + context: getApp().analytics.globalContext, + ); + + super.initState(); + } + + Widget _getWidget(int index) { + return index == _selectedItem + ? TextButton( + key: const ValueKey(kAnalyticsUpdateWidgetButton), + onPressed: () { + setState(() { + _analyticsEvent = AnalyticsEvent( + name: 'Update Analytics Impression $index', + data: { + 'name': 'Update Analytics Page Item $index', + }, + context: getApp().analytics.globalContext, + ); + }); + }, + child: const Text('test'), + ) + : const Text('test'); + } + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView.builder( + itemCount: _itemCount, + itemBuilder: (context, index) { + return SizedBox( + height: 100, + child: index == _selectedItem + ? AnalyticsContextProvider( + analyticsContext: AnalyticsContext({ + 'section': 'Section $index', + }), + child: AnalyticsImpressionWidget( + durationThreshold: Duration.zero, + visibleThreshold: 0.5, + event: _analyticsEvent, + child: _getWidget(index), + ), + ) + : _getWidget(index), + ); + }, + ), + ); + } +} + +class _TestAnalyticsImpressionNotifyDataChangedPage extends StandardPage { + final int _itemCount = 35; + Map? _data = { + 'name': 'Analytics Page Item', + }; + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView.builder( + itemCount: _itemCount, + itemBuilder: (context, index) { + if (index != 0 && index % 7 == 0) { + return TextButton( + key: const ValueKey(kAnalyticsNotifyDataChangedButton), + onPressed: () { + setState(() { + _data = { + 'name': 'Analytics Page Item After Setstate $index', + }; + }); + }, + child: Text('On Tap Button $index'), + ); + } + return SizedBox( + height: 100, + child: index % 10 == 0 + ? AnalyticsContextProvider( + analyticsContext: AnalyticsContext({ + 'section': 'Section $index', + }), + child: AnalyticsImpressionWidget( + durationThreshold: Duration.zero, + visibleThreshold: 0.5, + name: 'Analytics Impression', + data: _data, + child: Text("Impression index : $index"), + batchGenerator: (datas, contexts) { + return { + 'name': datas.map((e) => e['name']).join(','), + 'section': contexts + .map((e) => e.resolve()['section']) + .join(','), + }; + }, + ), + ) + : Text("Impression index : $index"), + ); + }, + ), + ); + } +} + +class _TestAnalyticsImpressionBatchToIgnorePage extends StandardPage { + final int _itemCount = 35; + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView.builder( + itemCount: _itemCount, + itemBuilder: (context, index) { + return SizedBox( + height: 100, + child: index % 10 == 0 + ? AnalyticsContextProvider( + analyticsContext: AnalyticsContext({ + 'section': 'Section $index', + 'index': '$index', + }), + child: AnalyticsImpressionWidget( + durationThreshold: Duration.zero, + name: 'Analytics Impression', + thresholdCallback: (info) { + return true; + }, + batchDataToIgnore: const {'section'}, // 例えばsectionは無視する + child: Text("Impression index : $index"), + batchGenerator: (datas, contexts) { + return {}; + }, + ), + ) + : Text("Impression index : $index"), + ); + }, + ), + ); + } +} + +class _TestAnalyticsImpressionVisibilityPage extends StandardPage { + final int _itemCount = 30; + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView.builder( + itemCount: _itemCount, + itemBuilder: (context, index) { + return SizedBox( + height: 100, + child: index == 10 + ? AnalyticsContextProvider( + analyticsContext: AnalyticsContext({ + 'section': 'Section $index', + }), + child: AnalyticsImpressionWidget( + durationThreshold: Duration.zero, + visibleThreshold: 0.5, + name: 'Analytics Impression $index', + data: { + 'name': 'Analytics Page Item $index', + }, + child: Text("Impression index : $index"), + batchGenerator: (datas, contexts) { + return {}; + }, + ), + ) + : Text("Impression index : $index"), + ); + }, + ), + ); + } +} + +class _TestAnalyticsImpressionThresholdCallbackPage extends StandardPage { + final int _itemCount = 50; + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'title')), + ), + body: ListView.builder( + itemCount: _itemCount, + itemBuilder: (context, index) { + return SizedBox( + height: 100, + child: index % 10 == 0 + ? AnalyticsContextProvider( + analyticsContext: AnalyticsContext({ + 'section': 'Section $index', + 'index': '$index', + }), + child: AnalyticsImpressionWidget( + name: 'Analytics Impression $index', + thresholdCallback: (info) { + return info.visibleFraction >= 0.5; + }, + child: Text("Impression index : $index"), + batchGenerator: (datas, contexts) { + return {}; + }, + ), + ) + : Text("Impression index : $index"), + ); + }, + ), + ); + } +} + +// Other Test Widgets +class _TestAnalyticsHomeScreen extends StatelessWidget { + const _TestAnalyticsHomeScreen({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Test Home Screen'), + ), + body: Center( + child: ElevatedButton( + key: const ValueKey(kAnalyticsHomeScreenButton), + onPressed: () { + Navigator.pushNamed(context, '/second', + arguments: 'Hello from Test Home Screen'); + }, + child: const Text('Go to Test Second Screen'), + ), + ), + ); + } +} + +class _TestAnalyticsSecondScreen extends StatelessWidget { + const _TestAnalyticsSecondScreen({ + Key? key, + }) : super(key: key); + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Test Second Screen'), + ), + body: Center( + child: Column( + children: [ + ElevatedButton( + key: const ValueKey(kAnalyticsSecondScreenButton), + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Go back to Test Home Screen'), + ), + ElevatedButton( + key: const ValueKey(kAnalyticsSecondRemoveScreenButton), + onPressed: () async { + Navigator.removeRoute(context, ModalRoute.of(context)!); + }, + child: const Text('Remove to Test Screen'), + ), + ], + ), + ), + ); + } +} + +class _TestFirstPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Test First Page'), + ), + body: Center( + child: ElevatedButton( + key: const ValueKey(kAnalyticsTestFirstPageButton), + onPressed: () { + context.go<_TestSecondPage, void>(null); + }, + child: const Text('Go to Test Second Page'), + ), + ), + ); + } +} + +class _TestSecondPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Test Second Page'), + ), + body: const Center( + child: Text('Test Second Page'), + ), + ); + } +} + +void main() { + // widget tests + group('Analytics Wdget Tests', () { + testWidgets("AnalyticsEvent eventsFor test", (WidgetTester tester) async { + final App tApp = createApp( + environment: const _TestAnalyticsEnviroment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.expand( + child: Center( + child: SizedBox(), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + final Stream tEventStream = + tAnalytics.eventsFor<_TestRawEvent>(); + + var tFuture = expectLater( + tEventStream, + emitsInOrder([AnalyticsEvent(name: 'testEvents')]), + ); + + // send analytics event + tAnalytics.rawEvent(AnalyticsEvent(name: 'testEvents')); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("AnalyticsEvent filter event test", + (WidgetTester tester) async { + final App tApp = createApp( + environment: _TestAnalyticsEventFilterEnviroment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.expand( + child: Center( + child: SizedBox(), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + final Stream tEventStream = + tAnalytics.eventsFor<_TestAnalyticsEvent>(); + + var tFuture = expectLater( + tEventStream, + emitsInOrder([_TestRawEvent(name: 'testEvents')]), + ); + + // send analytics event + tAnalytics.rawEvent(_TestAnalyticsEvent(name: 'testEvents')); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("AnalyticsEvent context test", (WidgetTester tester) async { + final App tApp = createApp( + environment: _TestAnalyticsEventFilterEnviroment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.expand( + child: Center( + child: SizedBox(), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tContextKey = Object(); + final tAnalyticsContext = AnalyticsContext({}); + + tApp.analytics.setGlobalContext(tContextKey, tAnalyticsContext); + expect(tApp.analytics.getGlobalContext(tContextKey), tAnalyticsContext); + + tApp.analytics.setGlobalContext(tContextKey, null); + expect(tApp.analytics.getGlobalContext(tContextKey), isNull); + }); + + tApp.dispose(); + }); + + testWidgets("SetRouteContext test", (WidgetTester tester) async { + final App tApp = createApp( + environment: const _TestAnalyticsEnviroment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.expand( + child: Center( + child: SizedBox(), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tKey = Object(); + final tContext = AnalyticsContext({}); + + tApp.analytics.setRouteContext(tKey, tContext); + expect(tApp.analytics.getRouteContext(tKey), tContext); + + tApp.analytics.setRouteContext(tKey, null); + expect(tApp.analytics.getRouteContext(tKey), isNull); + }); + + tApp.dispose(); + }); + + testWidgets("GetRouteContext test navigate page", + (WidgetTester tester) async { + final App tApp = createApp( + environment: const _TestAnalyticsEnviroment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestFirstPage, void>( + create: (data) => _TestFirstPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + ), + StandardPageFactory<_TestSecondPage, void>( + create: (data) => _TestSecondPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tKey = Object(); + final tContext = AnalyticsContext({}); + + tApp.analytics.setRouteContext(tKey, tContext); + expect(tApp.analytics.getRouteContext(tKey), tContext); + + await tester.pumpAndSettle(); + + await tester + .tap(find.byKey(const ValueKey(kAnalyticsTestFirstPageButton))); + + await tester.pumpAndSettle(); + + expect(tApp.analytics.getRouteContext(tKey), isNull); + }); + + tApp.dispose(); + }); + + testWidgets("Test sending analytics with null context", + (WidgetTester tester) async { + final App tApp = createApp( + environment: _TestAnalyticsEventFilterEnviroment(), + appWidget: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Builder(builder: (context) { + return SizedBox.expand( + child: Center( + child: SizedBox( + child: TextButton( + key: const ValueKey(kAnalyticsButton), + onPressed: () { + getApp().analytics.event( + name: 'testEvents', + context: null, + ); + }, + child: const Text('On Tap'), + ), + ), + ), + ); + }), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + final Stream tEventStream = + tAnalytics.eventsFor<_TestAnalyticsEvent>(); + + var tFuture = expectLater( + tEventStream, + emitsInOrder([AnalyticsEvent(name: 'testEvents')]), + ); + + await tester.tap(find.byKey(const ValueKey(kAnalyticsButton))); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("Analytics revenueEvent test", (WidgetTester tester) async { + AnalyticsRevenueEvent fConvertAnalyticsRevenueEvent( + AnalyticsEvent event) { + return event as AnalyticsRevenueEvent; + } + + final App tApp = createApp( + environment: const _TestAnalyticsEnviroment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.expand( + child: Center( + child: SizedBox(), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream, + emitsInOrder([ + AnalyticsRevenueEvent( + revenue: 100.0, + currency: 'USD', + orderId: '123456', + receipt: 'receipt123', + productId: 'product123', + productName: 'Product 123', + eventName: 'revenueEvent', + context: null, + ), + ]), + ); + + final List tFutureGetPropertyList = [ + expectLater( + tAnalytics.events.asyncMap( + (event) => fConvertAnalyticsRevenueEvent(event).revenue), + emitsInOrder([100.0]), + ), + expectLater( + tAnalytics.events.asyncMap( + (event) => fConvertAnalyticsRevenueEvent(event).currency), + emitsInOrder(['USD']), + ), + expectLater( + tAnalytics.events.asyncMap( + (event) => fConvertAnalyticsRevenueEvent(event).orderId), + emitsInOrder(['123456']), + ), + expectLater( + tAnalytics.events.asyncMap( + (event) => fConvertAnalyticsRevenueEvent(event).receipt), + emitsInOrder(['receipt123']), + ), + expectLater( + tAnalytics.events.asyncMap( + (event) => fConvertAnalyticsRevenueEvent(event).productId), + emitsInOrder(['product123']), + ), + expectLater( + tAnalytics.events.asyncMap( + (event) => fConvertAnalyticsRevenueEvent(event).productName), + emitsInOrder(['Product 123']), + ), + ]; + + // send revenue event + tAnalytics.revenueEvent( + revenue: 100.0, + currency: 'USD', + orderId: '123456', + receipt: 'receipt123', + productId: 'product123', + productName: 'Product 123', + eventName: 'revenueEvent', + ); + + await tFuture; + + await Future.wait(tFutureGetPropertyList); + }); + + tApp.dispose(); + }); + + testWidgets("AnalyticsEventWidget test", (WidgetTester tester) async { + final App tApp = createApp( + environment: const _TestAnalyticsEnviroment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.expand( + child: Center( + key: ValueKey(kAnalyticsEventButton), + child: AnalyticsEventWidget( + name: 'testEvents', + data: { + 'test': 'testData', + }, + child: Text('On Tap'), + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => event.toString(), + ), + emitsInOrder([ + 'AnalyticsEvent:testEvents: data={test: testData}, context={}, navigationInteractionContext=null' + ]), + ); + + await tester.tap(find.byKey(const ValueKey(kAnalyticsEventButton))); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("AnalyticsEventWidget raw test", (WidgetTester tester) async { + final App tApp = createApp( + environment: const _TestAnalyticsEnviroment(), + appWidget: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.expand( + child: Center( + key: const ValueKey(kAnalyticsRawEventButton), + child: AnalyticsEventWidget( + event: AnalyticsEvent( + name: 'eventRawName', + data: { + 'testRaw': 'testRawData', + }, + ), + child: const Text('On Tap'), + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFutureRawEvent = expectLater( + tEventStream.asyncMap( + (event) => event.toString(), + ), + emitsInOrder([ + 'AnalyticsEvent:eventRawName: data={testRaw: testRawData}, context=null, navigationInteractionContext=null' + ]), + ); + + await tester.tap(find.byKey(const ValueKey(kAnalyticsRawEventButton))); + + await tester.pumpAndSettle(); + + await tFutureRawEvent; + }); + + tApp.dispose(); + }); + + testWidgets("AnalyticsNavigatorObserver test", (WidgetTester tester) async { + final Analytics tAnalytics = Analytics(); + + final App tApp = createApp( + environment: const _TestAnalyticsEnviroment(), + appWidget: MaterialApp( + navigatorObservers: [ + AnalyticsNavigatorObserver(analytics: tAnalytics), + ], + home: const _TestAnalyticsHomeScreen(), + routes: { + '/second': (context) => const _TestAnalyticsSecondScreen(), + }, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) { + AnalyticsRouteViewEvent tEvent = event as AnalyticsRouteViewEvent; + return { + 'isFirst': tEvent.isFirst, + 'arguments': tEvent.arguments, + 'routeName': tEvent.routeName, + 'navigationType': tEvent.navigationType, + }; + }, + ), + emitsInOrder([ + { + 'isFirst': false, + 'arguments': 'Hello from Test Home Screen', + 'routeName': '/second', + 'navigationType': 'push', + } + ]), + ); + + await tester + .tap(find.byKey(const ValueKey(kAnalyticsHomeScreenButton))); + + await tester.pumpAndSettle(); + + await tFuture; + + tFuture = expectLater( + tEventStream.asyncMap( + (event) { + AnalyticsRouteViewEvent tEvent = event as AnalyticsRouteViewEvent; + return { + 'isFirst': tEvent.isFirst, + 'arguments': tEvent.arguments, + 'routeName': tEvent.routeName, + 'navigationType': tEvent.navigationType, + }; + }, + ), + emitsInOrder([ + { + 'isFirst': true, + 'arguments': null, + 'routeName': '/', + 'navigationType': 'pop', + } + ]), + ); + + await tester + .tap(find.byKey(const ValueKey(kAnalyticsSecondScreenButton))); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("AnalyticsNavigatorObserver remove route test", + (WidgetTester tester) async { + final Analytics tAnalytics = Analytics(); + + final App tApp = createApp( + environment: const _TestAnalyticsEnviroment(), + appWidget: MaterialApp( + navigatorObservers: [ + AnalyticsNavigatorObserver(analytics: tAnalytics), + ], + home: const _TestAnalyticsHomeScreen(), + routes: { + '/second': (context) => const _TestAnalyticsSecondScreen(), + }, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'isFirst': event.flatData?['isFirst'], + 'routeName': event.flatData?['routeName'], + 'navigationType': event.flatData?['navigationType'], + }, + ), + emitsInOrder([ + { + 'isFirst': false, + 'routeName': '/second', + 'navigationType': 'push', + } + ]), + ); + + await tester + .tap(find.byKey(const ValueKey(kAnalyticsHomeScreenButton))); + + await tester.pumpAndSettle(); + + await tFuture; + + tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'isFirst': event.flatData?['isFirst'], + 'routeName': event.flatData?['routeName'], + 'navigationType': event.flatData?['navigationType'], + }, + ), + emitsInOrder([ + { + 'isFirst': true, + 'routeName': '/', + 'navigationType': 'remove', + } + ]), + ); + + await tester.tap( + find.byKey(const ValueKey(kAnalyticsSecondRemoveScreenButton))); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + }); + + // StandardPages tests + group('Analytics Standard Page Tests', () { + testWidgets("Analytics flatData test", (WidgetTester tester) async { + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsPage, void>( + create: (data) => _TestAnalyticsPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + final tAnalytics = tApp.analytics; + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.flatData?['pageName'], + 'pageLink': event.flatData?['pageLink'], + 'test': event.flatData?['test'], + }, + ), + emitsInOrder([ + { + 'pageName': '_TestAnalyticsPage', + 'pageLink': '', + 'test': 'testData', + } + ]), + ); + + await tester + .tap(find.byKey(const ValueKey(kAnalyticsStandardPageButton))); + + await tester.pumpAndSettle(); + + await tFuture; + + expect( + find.text( + 'context.read().interactionContextData:interactionContext:{pageName: _TestAnalyticsPage, pageData: null, pageLink: }', + ), + findsOneWidget, + ); + }); + + tApp.dispose(); + }); + + testWidgets("Analytics context provider reset test", + (WidgetTester tester) async { + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsContextProviderPage, void>( + create: (data) => _TestAnalyticsContextProviderPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tAnalytics = tApp.analytics; + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': 'AnalyticsContextPage', + 'pageData': null, + 'pageLink': 'AnalyticsContextPageLink', + }, + ), + emitsInOrder([ + { + 'pageName': 'AnalyticsContextPage', + 'pageData': null, + 'pageLink': 'AnalyticsContextPageLink', + } + ]), + ); + + await tester.tap(find.byKey(const ValueKey(kAnalyticsButton))); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("Analytics singleton event widget test", + (WidgetTester tester) async { + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsSingletonEventWidgetPage, void>( + create: (data) => _TestAnalyticsSingletonEventWidgetPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tAnalytics = tApp.analytics; + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.name, + 'eventData': event.data, + }, + ), + emitsInOrder([ + { + 'pageName': 'Event1', + 'eventData': {'test': 'testData1'}, + } + ]), + ); + + await tester.tap(find.byKey(const ValueKey(kAnalyticsSingletonButton))); + + await tester.pumpAndSettle(); + + await tFuture; + + tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.name, + 'eventData': event.data, + }, + ), + emitsInOrder([ + { + 'pageName': 'Event2', + 'eventData': {'test': 'testData2'}, + } + ]), + ); + + await tester.tap(find.byKey(const ValueKey(kAnalyticsSingletonButton))); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("Analytics impression test", (WidgetTester tester) async { + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsImpressionWidgetPage, void>( + create: (data) => _TestAnalyticsImpressionWidgetPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + await tester.pumpAndSettle(); + + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.name, + 'eventData': event.data, + }, + ), + emitsInOrder([ + { + 'pageName': 'Analytics Impression', + 'eventData': { + 'name': 'Analytics Page Item 5', + 'section': 'Section 5' + } + } + ]), + ); + + await tester.drag(find.byType(ListView), const Offset(0, -100)); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("Analytics impression did update widget test", + (WidgetTester tester) async { + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsImpressionDidUpdateWidgetPage, + void>( + create: (data) => _TestAnalyticsImpressionDidUpdateWidgetPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + await tester.pumpAndSettle(); + + final Stream tEventStream = + tAnalytics.eventsFor(); + + await tester.drag(find.byType(ListView), const Offset(0, -100)); + + await tester.pumpAndSettle(); + + await tester + .tap(find.byKey(const ValueKey(kAnalyticsUpdateWidgetButton))); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.name, + 'eventData': event.data, + }, + ), + emitsInOrder([ + { + 'pageName': 'Update Analytics Impression 5', + 'eventData': {'name': 'Update Analytics Page Item 5'} + } + ]), + ); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("Analytics impression None batch test", + (WidgetTester tester) async { + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsImpressionWidgetNoneBatchPage, + void>( + create: (data) => _TestAnalyticsImpressionWidgetNoneBatchPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + await tester.pumpAndSettle(); + + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.name, + 'eventData': event.data, + }, + ), + emitsInOrder([ + { + 'pageName': 'Analytics Impression', + 'eventData': {'name': 'Analytics Page Item 30'} + } + ]), + ); + + await tester.drag(find.byType(ListView), const Offset(0, -3000)); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("Analytics impression notify data changed test", + (WidgetTester tester) async { + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsImpressionNotifyDataChangedPage, + void>( + create: (data) => _TestAnalyticsImpressionNotifyDataChangedPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + await tester.pumpAndSettle(); + + final Stream tEventStream = + tAnalytics.eventsFor(); + + await tester.drag(find.byType(ListView), const Offset(0, -500)); + + await tester.pumpAndSettle(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.name, + 'eventData': event.data, + }, + ), + emitsInOrder([ + { + 'pageName': 'Analytics Impression', + 'eventData': { + 'name': 'Analytics Page Item After Setstate 7', + 'section': 'Section 10' + } + } + ]), + ); + + await tester + .tap(find.byKey(const ValueKey(kAnalyticsNotifyDataChangedButton))); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("Analytics impression batch to ignore data test", + (WidgetTester tester) async { + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsImpressionBatchToIgnorePage, + void>( + create: (data) => _TestAnalyticsImpressionBatchToIgnorePage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + await tester.pumpAndSettle(); + + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.flatData?['pageName'], + 'index': event.flatData?['index'], + 'section': event.flatData?['section'], + }, + ), + emitsInOrder([ + { + 'pageName': '_TestAnalyticsImpressionBatchToIgnorePage', + 'index': '30', + 'section': null, + } + ]), + ); + + await tester.drag(find.byType(ListView), const Offset(0, -3000)); + + await tester.pumpAndSettle(); + + await tFuture; + }); + + tApp.dispose(); + }); + + testWidgets("Analytics impression visibility test", + (WidgetTester tester) async { + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsImpressionVisibilityPage, void>( + create: (data) => _TestAnalyticsImpressionVisibilityPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + await tester.pumpAndSettle(); + + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.name, + }, + ), + emitsInOrder([ + { + 'pageName': 'Analytics Impression 10', + } + ]), + ); + + // If you scroll too quickly, the VisibilityChanged won't be triggered, so scroll slowly. + await tester.timedDrag(find.byType(ListView), const Offset(0, -500), + const Duration(seconds: 1)); + + await tester.pumpAndSettle(); + + // Check the state before scrolling. + expect(find.text('Impression index : 10'), findsOneWidget); + + await tFuture; + + // If you scroll too quickly, the VisibilityChanged won't be triggered, so scroll slowly. + await tester.timedDrag(find.byType(ListView), const Offset(0, -500), + const Duration(seconds: 1)); + + await tester.pumpAndSettle(); + + // Check the state after scrolling. + expect(find.text('Impression index : 10'), findsNothing); + }); + + tApp.dispose(); + }); + + testWidgets("Analytics impression threshold callback test", + (WidgetTester tester) async { + VisibilityDetectorController.instance.updateInterval = Duration.zero; + + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory<_TestAnalyticsImpressionThresholdCallbackPage, + void>( + create: (data) => _TestAnalyticsImpressionThresholdCallbackPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + ], + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final tAnalytics = tApp.analytics; + + await tester.pumpAndSettle(); + + final Stream tEventStream = + tAnalytics.eventsFor(); + + var tFuture = expectLater( + tEventStream.asyncMap( + (event) => { + 'pageName': event.name, + }, + ), + emitsInOrder([ + { + 'pageName': 'Analytics Impression 10', + } + ]), + ); + + // If you scroll too quickly, the VisibilityChanged won't be triggered, so scroll slowly. + await tester.timedDrag(find.byType(ListView), const Offset(0, -500), + const Duration(seconds: 2)); + + await tester.pumpAndSettle(); + + // Check the state before scrolling. + expect(find.text('Impression index : 10'), findsOneWidget); + + // If you scroll too quickly, the VisibilityChanged won't be triggered, so scroll slowly. + await tester.timedDrag(find.byType(ListView), const Offset(0, -800), + const Duration(seconds: 2)); + + await tester.pumpAndSettle(); + + await tFuture; + + // Check the state after scrolling. + expect(find.text('Impression index : 10'), findsNothing); + }); + + tApp.dispose(); + }); + }); + + // unit tests + group('Analytics Unit Tests', () { + // defaultMakeLoggableToNative tests + test('defaultMakeLoggableToNative test with null object', () { + const object = null; + final result = Analytics.defaultMakeLoggableToNative(object); + expect(result, equals('')); + }); + + test('defaultMakeLoggableToNative test with int object', () { + const object = 123; + final result = Analytics.defaultMakeLoggableToNative(object); + expect(result, equals(object)); + }); + + test('defaultMakeLoggableToNative test with double object', () { + const object = 123.456; + final result = Analytics.defaultMakeLoggableToNative(object); + expect(result, equals(object)); + }); + + test('defaultMakeLoggableToNative test with String object', () { + const object = 'This is a test string'; + final result = Analytics.defaultMakeLoggableToNative(object); + expect(result, equals(object.characters.take(100).toString())); + }); + + test('defaultMakeLoggableToNative test with long String object', () { + final object = 'a' * 200; + final result = Analytics.defaultMakeLoggableToNative(object); + expect(result, equals(object.characters.take(100).toString())); + }); + + test('defaultMakeLoggableToNative test with json encodable object', () { + final object = {'key': 'value'}; + final result = Analytics.defaultMakeLoggableToNative(object); + expect( + result, equals(jsonEncode(object).characters.take(100).toString())); + }); + + test('defaultMakeLoggableToNative test with non-json encodable object', () { + final object = Object(); + final result = Analytics.defaultMakeLoggableToNative(object); + expect(result, equals(object.toString())); + }); + + // tryConvertToLoggableJsonParameters tests + test('tryConvertToLoggableJsonParameters test with null object', () { + final result = Analytics.tryConvertToLoggableJsonParameters('', null); + expect(result, equals({})); + }); + + test('tryConvertToLoggableJsonParameters test with String object', () { + const object = 'This is a test string'; + final result = Analytics.tryConvertToLoggableJsonParameters('', object); + expect(result, equals({'1': object})); + }); + + test('tryConvertToLoggableJsonParameters test with int object', () { + const object = 123; + final result = Analytics.tryConvertToLoggableJsonParameters('', object); + expect(result, equals({'1': object.toString()})); + }); + + test('tryConvertToLoggableJsonParameters test with double object', () { + const object = 123.456; + final result = Analytics.tryConvertToLoggableJsonParameters('', object); + expect(result, equals({'1': object.toString()})); + }); + + test('tryConvertToLoggableJsonParameters test with json encodable object', + () { + final object = {'key': 'value'}; + final result = Analytics.tryConvertToLoggableJsonParameters('', object); + expect(result, equals({'1': jsonEncode(object)})); + }); + + test( + 'tryConvertToLoggableJsonParameters test with non-json encodable object', + () { + final object = Object(); + final result = Analytics.tryConvertToLoggableJsonParameters('', object); + expect(result, equals({})); + }); + + // hashcode tests + test('AnalyticsContext hashCode test', () { + final analyticsContext1 = AnalyticsContext({'key': 'value'}); + final analyticsContext2 = AnalyticsContext({'key': 'value'}); + + expect(analyticsContext1.hashCode, equals(analyticsContext2.hashCode)); + + final analyticsContext3 = AnalyticsContext({'key': 'otherValue'}); + + expect(analyticsContext1.hashCode, + isNot(equals(analyticsContext3.hashCode))); + }); + }); +} diff --git a/packages/patapata_core/test/app_test.dart b/packages/patapata_core/test/app_test.dart new file mode 100644 index 0000000..a0a7aff --- /dev/null +++ b/packages/patapata_core/test/app_test.dart @@ -0,0 +1,837 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; + +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +class _EnvironmentBase {} + +class _Environment extends _EnvironmentBase {} + +class ClassA {} + +class TestPluginBase extends Plugin {} + +class TestPluginA extends TestPluginBase {} + +class TestPluginB extends TestPluginBase { + @override + final bool requireRemoteConfig = true; +} + +class TestPluginC extends TestPluginBase {} + +class MyCustomUser extends User { + MyCustomUser(App app) : super(app: app); +} + +class FetchFailsRemoteConfig extends MockRemoteConfig { + FetchFailsRemoteConfig(super.store); + + @override + Future fetch({ + Duration expiration = const Duration(hours: 5), + bool force = false, + }) async { + throw Exception(); + } +} + +class _TestException extends PatapataException { + const _TestException(this._level); + + final Level? _level; + + @override + Level? get logLevel => _level; + + @override + String get defaultPrefix => 'TST'; + + @override + String get internalCode => '000'; + + @override + String get namespace => 'test'; +} + +void main() { + late App<_Environment> tApp; + final tWidgetKey = GlobalKey(); + + group('App initialization test.', () { + setUp(() { + testInitialize(); + + tApp = App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + ); + }); + + testWidgets('App run test.', (tester) async { + final tAppStageChangeStream = + App.appStageChangeStream.asyncMap((event) => event.stage); + expectLater( + tAppStageChangeStream, + emitsInOrder([ + AppStage.setup, + AppStage.bootstrap, + AppStage.initializingPlugins, + AppStage.setupRemoteConfig, + AppStage.initializingPluginsWithRemoteConfig, + AppStage.running, + AppStage.disposed, + ]), + ); + + expect(tApp.stage, equals(AppStage.setup)); + + final tResult = await tApp.run(); + + expect(tResult, isTrue); + expect(tApp.stage, equals(AppStage.running)); + + tApp.dispose(); + + expect(tApp.stage, equals(AppStage.disposed)); + }); + + testWidgets('App runProcess and appWidget test.', (tester) async { + await tApp.run(); + + final tResult = await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.stage, equals(AppStage.running)); + expect(tApp.environment, isA<_Environment>()); + expect(tApp.environmentAs<_EnvironmentBase>(), isA<_EnvironmentBase>()); + expect(tApp.environmentAs(), isNull); + expect(find.byKey(tWidgetKey), findsOneWidget); + + return true; + }); + + expect(tResult, isTrue); + expect(tApp.stage, equals(AppStage.running)); + + tApp.dispose(); + }); + + testWidgets('App run bootstrapCallback test.', (tester) async { + late final AppStage tBootstrapCallbackAppStage; + await tApp.run(() { + tBootstrapCallbackAppStage = tApp.stage; + }); + + expect(tBootstrapCallbackAppStage, equals(AppStage.bootstrap)); + expect(tApp.stage, equals(AppStage.running)); + + tApp.dispose(); + }); + + testWidgets('Each required plugins and models is correctly initialized.', + (tester) async { + await tApp.run(); + + // plugins + expect(tApp.network, isA()); + expect(tApp.package, isA()); + expect(tApp.device, isA()); + expect(tApp.getPlugin(), isA()); + expect(tApp.getPlugin(), + isA()); + expect(tApp.getPlugin(), isA()); + expect(tApp.getPlugin(), isA()); + + // models + expect(tApp.log, isA()); + expect(tApp.user, isA()); + expect(tApp.analytics, isA()); + expect(tApp.permissions, isA()); + expect(tApp.remoteConfig, isA()); + expect(tApp.localConfig, isA()); + expect(tApp.remoteMessaging, isA()); + expect(tApp.navigatorObservers.length, equals(1)); + expect(tApp.navigatorObservers.first, isA()); + expect(tApp.startupSequence, isNull); + + tApp.dispose(); + }); + + testWidgets('Each Provider can be obtained with getProvider.', + (tester) async { + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider>(), isA>()); + expect(tApp.getProvider<_Environment>(), isA<_Environment>()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect( + tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + + // Can also be obtained from [context.read]. + final tContext = tWidgetKey.currentContext!; + expect(tContext.read(), isA()); + expect(tContext.read>(), isA>()); + expect(tContext.read<_Environment>(), isA<_Environment>()); + expect(tContext.read(), isA()); + expect(tContext.read(), isA()); + expect(tContext.read(), isA()); + expect(tContext.read(), isA()); + expect(tContext.read(), isA()); + expect(tContext.read(), isA()); + expect(tContext.read(), isA()); + expect(tContext.read(), isA()); + expect(tContext.read(), isA()); + }); + + tApp.dispose(); + }); + + testWidgets('getApp test.', (tester) async { + await tApp.run(); + + // Cannot be obtained outside the Zone of App. + expect(getApp, throwsA(isA())); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(getApp(), equals(tApp)); + }); + + tApp.dispose(); + }); + + testWidgets('addPlugin and removePlugin test.', (tester) async { + final tPluginA = TestPluginA(); + final tPluginB = TestPluginB(); + final tPluginC = TestPluginC(); + + await tApp.addPlugin(tPluginC); + + expect(tApp.hasPlugin(TestPluginC), isTrue); + expect(tApp.getPlugin(), equals(tPluginC)); + expect(tApp.getPluginsOfType(), equals([tPluginC])); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.hasPlugin(TestPluginC), isTrue); + expect(tApp.getPlugin(), equals(tPluginC)); + expect(tApp.getPluginsOfType(), equals([tPluginC])); + + await tApp.addPlugin(tPluginA); + expect(tApp.hasPlugin(TestPluginA), isTrue); + expect(tApp.getPlugin(), equals(tPluginA)); + expect( + tApp.getPluginsOfType(), + equals([tPluginC, tPluginA]), + ); + + await tApp.addPlugin(tPluginB); + expect(tApp.hasPlugin(TestPluginB), isTrue); + expect(tApp.getPlugin(), equals(tPluginB)); + expect( + tApp.getPluginsOfType(), + equals([tPluginC, tPluginA, tPluginB]), + ); + + await tApp.removePlugin(tPluginA); + expect(tApp.hasPlugin(TestPluginA), isFalse); + expect(tApp.getPlugin(), isNull); + expect( + tApp.getPluginsOfType(), + equals([tPluginC, tPluginB]), + ); + + await tApp.removePlugin(tPluginB); + expect(tApp.hasPlugin(TestPluginB), isFalse); + expect(tApp.getPlugin(), isNull); + expect(tApp.getPluginsOfType(), equals([tPluginC])); + + await tApp.removePlugin(tPluginC); + expect(tApp.hasPlugin(TestPluginC), isFalse); + expect(tApp.getPlugin(), isNull); + expect(tApp.getPluginsOfType(), isEmpty); + }); + + tApp.dispose(); + }); + + testWidgets( + 'An Exception raised by runProcess is handled as an unknown error in the App zone and rethrown to the caller.', + (tester) async { + await tApp.run(); + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + }); + + // Check log levels processed within the App zone. + final tStream = tApp.log.reports.asyncMap((event) => event.level); + expectLater( + tStream, + emitsInOrder([ + Level.SHOUT, + Level.SEVERE, + Level.SEVERE, + ]), + ); + + Object? tException; + try { + await tApp.runProcess(() async { + throw const _TestException(Level.SHOUT); + }); + } catch (e) { + tException = e; + } + expect((tException as _TestException).logLevel, equals(Level.SHOUT)); + + // Even if the log level is less than SEVERE, an unknown error in the App zone is handled as Level.SEVERE. + tException = null; + try { + await tApp.runProcess(() async { + throw const _TestException(Level.INFO); + }); + } catch (e) { + tException = e; + } + expect(tException, isA<_TestException>()); + expect((tException as _TestException).logLevel, equals(Level.INFO)); + + // If logLevel is null, it is processed as Level.SEVERE in App zone. + tException = null; + try { + await tApp.runProcess(() async { + throw const _TestException(null); + }); + } catch (e) { + tException = e; + } + expect((tException as _TestException).logLevel, isNull); + + tApp.dispose(); + }); + }); + + group('App initialization test. options', () { + testWidgets('Use providerKey when initializing App.', (tester) async { + testInitialize(); + + final tProviderKey = GlobalKey(); + tApp = App( + environment: _Environment(), + providerKey: tProviderKey, + createAppWidget: (context, app) { + return MultiProvider( + providers: [ + Provider( + create: (context) => ClassA(), + ), + ], + child: KeyedSubtree( + key: tProviderKey, + child: MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ), + ), + ); + }, + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.getProvider(), isA()); + expect(tWidgetKey.currentContext!.read(), isA()); + + // Default providers can also be obtained. + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider>(), isA>()); + expect(tApp.getProvider<_Environment>(), isA<_Environment>()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect( + tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + expect(tApp.getProvider(), isA()); + }); + + tApp.dispose(); + }); + + testWidgets('Use userFactory when initializing App.', (tester) async { + testInitialize(); + + tApp = App( + environment: _Environment(), + userFactory: ((app) => MyCustomUser(app)), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.user, isA()); + }); + + tApp.dispose(); + }); + + testWidgets('Initialization of App using plugins.', (tester) async { + testInitialize(); + + late final AppStage tInitPluginAAppStage; + final tPluginA = Plugin.inline( + name: 'testPluginA', + init: (app) async { + tInitPluginAAppStage = app.stage; + return true; + }, + ); + late final AppStage tInitPluginBAppStage; + final tPluginB = Plugin.inline( + name: 'testPluginB', + init: (app) async { + tInitPluginBAppStage = app.stage; + return true; + }, + ); + + tApp = App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + plugins: [ + tPluginA, + ], + ); + + await tApp.run(); + expect(tApp.stage, equals(AppStage.running)); + expect(tInitPluginAAppStage, equals(AppStage.initializingPlugins)); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.getPlugin(), equals(tPluginA)); + + await tApp.addPlugin(tPluginB); + expect(tInitPluginBAppStage, equals(AppStage.running)); + + expect( + tApp.getPluginsOfType(), + equals([tPluginA, tPluginB]), + ); + + await tApp.removePlugin(tPluginA); + expect(tApp.getPlugin(), equals(tPluginB)); + expect(tApp.getPluginsOfType(), equals([tPluginB])); + }); + + tApp.dispose(); + }); + + testWidgets('Initialization of App using plugins. RemoteConfig required.', + (tester) async { + testInitialize(); + + late final AppStage tInitPluginAAppStage; + final tPluginA = Plugin.inline( + name: 'testPluginA', + init: (app) async { + tInitPluginAAppStage = app.stage; + return true; + }, + ); + late final AppStage tInitPluginBAppStage; + final tPluginB = Plugin.inline( + name: 'testPluginB', + requireRemoteConfig: true, + init: (app) async { + tInitPluginBAppStage = app.stage; + return true; + }, + ); + + tApp = App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + plugins: [ + tPluginA, + tPluginB, + ], + ); + + await tApp.run(); + expect(tApp.stage, equals(AppStage.running)); + expect(tInitPluginAAppStage, equals(AppStage.initializingPlugins)); + expect(tInitPluginBAppStage, + equals(AppStage.initializingPluginsWithRemoteConfig)); + + tApp.dispose(); + }); + + testWidgets( + 'If the plugin init returns false during initialization, the plugin is removed.', + (tester) async { + testInitialize(); + + fCreateApp(List plugins) { + return App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + plugins: plugins, + ); + } + + final tPluginA = Plugin.inline( + name: 'testPluginA', + init: (app) async { + return true; + }, + ); + final tPluginFA = Plugin.inline( + name: 'testPluginFA', + init: (app) async { + return false; + }, + ); + final tPluginB = Plugin.inline( + name: 'testPluginB', + requireRemoteConfig: true, + init: (app) async { + return true; + }, + ); + final tPluginFB = Plugin.inline( + name: 'testPluginFB', + requireRemoteConfig: true, + init: (app) async { + return false; + }, + ); + + tApp = fCreateApp([ + tPluginA, + tPluginFA, + ]); + await tApp.run(); + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.getPlugin(), equals(tPluginA)); + expect(tApp.getPluginsOfType(), equals([tPluginA])); + }); + tApp.dispose(); + + tApp = fCreateApp([ + tPluginB, + tPluginFB, + ]); + await tApp.run(); + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.getPlugin(), equals(tPluginB)); + expect(tApp.getPluginsOfType(), equals([tPluginB])); + }); + tApp.dispose(); + }); + + testWidgets( + 'If plugin init throws an exception during initialization, the application will fail to start.', + (tester) async { + testInitialize(); + + fCreateApp(List plugins) { + return App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + plugins: plugins, + ); + } + + final tPluginFA = Plugin.inline( + name: 'testPluginFA', + init: (app) async { + throw Exception(); + }, + ); + final tPluginFB = Plugin.inline( + name: 'testPluginFB', + requireRemoteConfig: true, + init: (app) async { + throw Exception(); + }, + ); + + tApp = fCreateApp([ + tPluginFA, + ]); + expect(await tApp.run(), isFalse); + expect(tApp.stage, AppStage.initializingPlugins); + tApp.dispose(); + + tApp = fCreateApp([ + tPluginFB, + ]); + expect(await tApp.run(), isFalse); + expect(tApp.stage, AppStage.initializingPluginsWithRemoteConfig); + tApp.dispose(); + }); + + testWidgets('If App.run fails, onInitFailure is called.', (tester) async { + testInitialize(); + + testInitialize(); + + final tPluginA = Plugin.inline( + name: 'testPluginA', + init: (app) async { + throw const _TestException(Level.SEVERE); + }, + ); + + Object? tException; + tApp = App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + plugins: [ + tPluginA, + ], + onInitFailure: (env, e, stackTrace) { + tException = e; + }, + ); + + expect(await tApp.run(), isFalse); + expect(tException, isA<_TestException>()); + + tApp.dispose(); + }); + + testWidgets('Use RemoteMessages.', (tester) async { + testInitialize(); + + const tRemoteMessage = RemoteMessage(); + final tMockRemoteMessaging = MockRemoteMessaging( + getInitialMessage: () async => tRemoteMessage, + ); + final tPluginA = Plugin.inline( + name: 'testPluginA', + createRemoteMessaging: () => tMockRemoteMessaging, + ); + + tApp = App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + plugins: [ + tPluginA, + ], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tMessage = await tApp.remoteMessaging.getInitialMessage(); + expect(tMessage, equals(tRemoteMessage)); + }); + + tApp.dispose(); + }); + + testWidgets( + 'Use RemoteConfig. If fetch fails during initialization, the application will still start.', + (tester) async { + testInitialize(); + + final tStoreA = { + 'a': 1, + 'patapata_log_level': Level.INFO.value, + }; + final tStoreB = { + 'b': 2, + }; + final tRemoteConfigA = MockRemoteConfig({}) + ..testSetMockFetchValues(tStoreA); + final tRemoteConfigB = FetchFailsRemoteConfig({}) + ..testSetMockFetchValues(tStoreB); + + final tPluginA = Plugin.inline( + name: 'testPluginA', + createRemoteConfig: () => tRemoteConfigA, + ); + final tPluginB = Plugin.inline( + name: 'testPluginB', + createRemoteConfig: () => tRemoteConfigB, + ); + + tApp = App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + plugins: [ + tPluginA, + tPluginB, + ], + ); + + expect(await tApp.run(), isTrue); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.remoteConfig.getInt('a'), equals(1)); + expect(tApp.remoteConfig.getInt('b'), equals(0)); + expect(tApp.log.level, equals(Level.INFO)); + + tStoreA['a'] = 10; + tStoreA['patapata_log_level'] = Level.SEVERE.value; + tRemoteConfigA.testSetMockFetchValues(tStoreA); + await tApp.remoteConfig.fetch(force: true); + expect(tApp.log.level, equals(Level.SEVERE)); + expect(tApp.remoteConfig.getInt('a'), equals(10)); + expect(tApp.remoteConfig.getInt('b'), equals(0)); + }); + + tApp.dispose(); + }); + + testWidgets('Plugin can be disabled in RemoteConfig.', (tester) async { + testInitialize(); + + final tPluginA = TestPluginA(); + final tPluginB = TestPluginB(); + final tPluginC = TestPluginC(); + + final tRemoteConfigA = MockRemoteConfig({}) + ..testSetMockFetchValues({ + tPluginA.remoteConfigEnabledKey: false, + tPluginB.remoteConfigEnabledKey: false, + tPluginC.remoteConfigEnabledKey: true, + }); + + final tRemoteConfigPlugin = Plugin.inline( + name: 'remoteConfigPlugin', + createRemoteConfig: () => tRemoteConfigA, + ); + + tApp = App( + environment: _Environment(), + createAppWidget: (context, app) { + return MaterialApp( + home: SizedBox.shrink( + key: tWidgetKey, + ), + ); + }, + plugins: [ + tPluginA, + tPluginB, + tPluginC, + tRemoteConfigPlugin, + ], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.hasPlugin(TestPluginA), isFalse); + expect(tApp.hasPlugin(TestPluginB), isFalse); + expect(tApp.hasPlugin(TestPluginC), isTrue); + }); + + tApp.dispose(); + }); + }); +} diff --git a/packages/patapata_core/test/assets/images/logo.svg b/packages/patapata_core/test/assets/images/logo.svg new file mode 100644 index 0000000..1b811ca --- /dev/null +++ b/packages/patapata_core/test/assets/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/patapata_core/test/device_info_test.dart b/packages/patapata_core/test/device_info_test.dart new file mode 100644 index 0000000..a25ef29 --- /dev/null +++ b/packages/patapata_core/test/device_info_test.dart @@ -0,0 +1,540 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +/// TODO: The arguments for "with arguments" +/// will be verified by checking the actual values +/// on the device during the time of going to the office and used as a reference. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('DeviceInfoPlugin.androidInfo', () { + late App app; + late DeviceInfoPlugin deviceInfoPlugin; + setUp(() { + app = createApp(); + deviceInfoPlugin = DeviceInfoPlugin(); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + }); + + test('init', () async { + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.androidDeviceInfo != null, isTrue); + expect(deviceInfoPlugin.androidDeviceInfo!.data, + DeviceInfoPlugin.mockAndroidDeviceInfoMap); + }); + + test('setMockAndroidDeviceInfo no arguments', () async { + DeviceInfoPlugin.setMockAndroidDeviceInfo(); + await deviceInfoPlugin.init(app); + + expect(deviceInfoPlugin.androidDeviceInfo, isNotNull); + expect(deviceInfoPlugin.androidDeviceInfo!.data, + DeviceInfoPlugin.mockAndroidDeviceInfoMap); + expect(deviceInfoPlugin.androidDeviceInfo!.id, 'id'); + expect(deviceInfoPlugin.androidDeviceInfo!.host, 'host'); + expect(deviceInfoPlugin.androidDeviceInfo!.tags, 'tags'); + expect(deviceInfoPlugin.androidDeviceInfo!.type, 'type'); + expect(deviceInfoPlugin.androidDeviceInfo!.model, 'model'); + expect(deviceInfoPlugin.androidDeviceInfo!.board, 'board'); + expect(deviceInfoPlugin.androidDeviceInfo!.brand, 'Google'); + expect(deviceInfoPlugin.androidDeviceInfo!.device, 'device'); + expect(deviceInfoPlugin.androidDeviceInfo!.product, 'product'); + expect(deviceInfoPlugin.androidDeviceInfo!.display, 'display'); + expect(deviceInfoPlugin.androidDeviceInfo!.hardware, 'hardware'); + expect(deviceInfoPlugin.androidDeviceInfo!.isPhysicalDevice, isTrue); + expect(deviceInfoPlugin.androidDeviceInfo!.bootloader, 'bootloader'); + expect(deviceInfoPlugin.androidDeviceInfo!.fingerprint, 'fingerprint'); + expect(deviceInfoPlugin.androidDeviceInfo!.manufacturer, 'manufacturer'); + expect(deviceInfoPlugin.androidDeviceInfo!.supportedAbis, + ['arm64-v8a', 'x86', 'x86_64']); + expect(deviceInfoPlugin.androidDeviceInfo!.systemFeatures, + ['FEATURE_AUDIO_PRO', 'FEATURE_AUDIO_OUTPUT']); + expect(deviceInfoPlugin.androidDeviceInfo!.version.sdkInt, 16); + expect(deviceInfoPlugin.androidDeviceInfo!.version.baseOS, 'baseOS'); + expect(deviceInfoPlugin.androidDeviceInfo!.version.previewSdkInt, 30); + expect(deviceInfoPlugin.androidDeviceInfo!.version.release, 'release'); + expect(deviceInfoPlugin.androidDeviceInfo!.version.codename, 'codename'); + expect(deviceInfoPlugin.androidDeviceInfo!.version.incremental, + 'incremental'); + expect(deviceInfoPlugin.androidDeviceInfo!.version.securityPatch, + 'securityPatch'); + expect(deviceInfoPlugin.androidDeviceInfo!.supported32BitAbis, + ['x86 (IA-32)', 'MMX']); + expect(deviceInfoPlugin.androidDeviceInfo!.supported64BitAbis, + ['x86-64', 'MMX', 'SSSE3']); + expect(deviceInfoPlugin.androidDeviceInfo!.displayMetrics.widthPx, 1080); + expect(deviceInfoPlugin.androidDeviceInfo!.displayMetrics.heightPx, 2220); + expect(deviceInfoPlugin.androidDeviceInfo!.displayMetrics.xDpi, 530.0859); + expect(deviceInfoPlugin.androidDeviceInfo!.displayMetrics.yDpi, 529.4639); + expect(deviceInfoPlugin.androidDeviceInfo!.serialNumber, 'serialNumber'); + }); + + test('setMockAndroidDeviceInfo with arguments', () async { + DeviceInfoPlugin.setMockAndroidDeviceInfo( + id: '', + host: '', + tags: '', + type: '', + model: '', + board: '', + brand: '', + device: '', + product: '', + display: '', + hardware: '', + isPhysicalDevice: false, + bootloader: '', + fingerprint: '', + manufacturer: '', + supportedAbis: [''], + systemFeatures: [''], + version: { + 'sdkInt': 0, + 'baseOS': '', + 'previewSdkInt': 0, + 'release': '', + 'codename': '', + 'incremental': '', + 'securityPatch': '', + }, + supported64BitAbis: [''], + supported32BitAbis: [''], + displayMetrics: { + 'widthPx': 0.0, + 'heightPx': 0.0, + 'xDpi': 0.0, + 'yDpi': 0.0, + }, + serialNumber: '', + ); + await deviceInfoPlugin.init(app); + + expect(deviceInfoPlugin.androidDeviceInfo != null, isTrue); + expect(deviceInfoPlugin.androidDeviceInfo!.data, + DeviceInfoPlugin.mockAndroidDeviceInfoMap); + expect(deviceInfoPlugin.androidDeviceInfo!.id, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.host, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.tags, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.type, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.model, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.board, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.brand, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.device, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.product, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.display, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.hardware, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.isPhysicalDevice, isFalse); + expect(deviceInfoPlugin.androidDeviceInfo!.bootloader, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.fingerprint, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.manufacturer, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.supportedAbis, ['']); + expect(deviceInfoPlugin.androidDeviceInfo!.systemFeatures, ['']); + expect(deviceInfoPlugin.androidDeviceInfo!.version.sdkInt, 0); + expect(deviceInfoPlugin.androidDeviceInfo!.version.baseOS, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.version.previewSdkInt, 0); + expect(deviceInfoPlugin.androidDeviceInfo!.version.release, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.version.codename, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.version.incremental, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.version.securityPatch, ''); + expect(deviceInfoPlugin.androidDeviceInfo!.supported32BitAbis, ['']); + expect(deviceInfoPlugin.androidDeviceInfo!.supported64BitAbis, ['']); + expect(deviceInfoPlugin.androidDeviceInfo!.displayMetrics.widthPx, 0); + expect(deviceInfoPlugin.androidDeviceInfo!.displayMetrics.heightPx, 0); + expect(deviceInfoPlugin.androidDeviceInfo!.displayMetrics.xDpi, 0); + expect(deviceInfoPlugin.androidDeviceInfo!.displayMetrics.yDpi, 0); + expect(deviceInfoPlugin.androidDeviceInfo!.serialNumber, ''); + }); + }); + + group('DeviceInfoPlugin.iosInfo', () { + late App app; + late DeviceInfoPlugin deviceInfoPlugin; + setUp(() { + app = createApp(); + deviceInfoPlugin = DeviceInfoPlugin(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + }); + + test('init', () async { + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.iosDeviceInfo != null, isTrue); + expect(deviceInfoPlugin.iosDeviceInfo!.data, + DeviceInfoPlugin.mockIosDeviceInfoMap); + }); + + test('setMockIosDeviceInfo no arguments', () async { + DeviceInfoPlugin.setMockIosDeviceInfo(); + await deviceInfoPlugin.init(app); + + expect(deviceInfoPlugin.iosDeviceInfo != null, isTrue); + expect(deviceInfoPlugin.iosDeviceInfo!.data, + DeviceInfoPlugin.mockIosDeviceInfoMap); + expect(deviceInfoPlugin.iosDeviceInfo!.name, 'name'); + expect(deviceInfoPlugin.iosDeviceInfo!.model, 'model'); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.release, 'release'); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.version, 'version'); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.machine, 'machine'); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.sysname, 'sysname'); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.nodename, 'nodename'); + expect(deviceInfoPlugin.iosDeviceInfo!.systemName, 'systemName'); + expect(deviceInfoPlugin.iosDeviceInfo!.systemVersion, 'systemVersion'); + expect(deviceInfoPlugin.iosDeviceInfo!.localizedModel, 'localizedModel'); + expect(deviceInfoPlugin.iosDeviceInfo!.isPhysicalDevice, isTrue); + expect(deviceInfoPlugin.iosDeviceInfo!.identifierForVendor, + 'identifierForVendor'); + }); + + test('setMockIosDeviceInfo with arguments', () async { + DeviceInfoPlugin.setMockIosDeviceInfo( + name: '', + model: '', + utsname: { + 'release': '', + 'version': '', + 'machine': '', + 'sysname': '', + 'nodename': '', + }, + systemName: '', + systemVersion: '', + isPhysicalDevice: false, + localizedModel: '', + identifierForVendor: '', + ); + await deviceInfoPlugin.init(app); + + expect(deviceInfoPlugin.iosDeviceInfo != null, isTrue); + expect(deviceInfoPlugin.iosDeviceInfo!.data, + DeviceInfoPlugin.mockIosDeviceInfoMap); + expect(deviceInfoPlugin.iosDeviceInfo!.name, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.model, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.release, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.version, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.machine, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.sysname, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.utsname.nodename, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.systemName, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.systemVersion, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.localizedModel, ''); + expect(deviceInfoPlugin.iosDeviceInfo!.isPhysicalDevice, isFalse); + expect(deviceInfoPlugin.iosDeviceInfo!.identifierForVendor, ''); + }); + }); + + group('DeviceInfoPlugin.macOsInfo', () { + late App app; + late DeviceInfoPlugin deviceInfoPlugin; + setUp(() { + app = createApp(); + deviceInfoPlugin = DeviceInfoPlugin(); + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + }); + + test('init', () async { + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.macOsInfo != null, isTrue); + expect(deviceInfoPlugin.macOsInfo!.data, + DeviceInfoPlugin.mockMacosDeviceInfoMap); + }); + + test('setMockMacosDeviceInfoMap no arguments', () async { + DeviceInfoPlugin.setMockMacosDeviceInfo(); + await deviceInfoPlugin.init(app); + + expect(deviceInfoPlugin.macOsInfo != null, isTrue); + expect(deviceInfoPlugin.macOsInfo!.data, + DeviceInfoPlugin.mockMacosDeviceInfoMap); + expect(deviceInfoPlugin.macOsInfo!.arch, 'arch'); + expect(deviceInfoPlugin.macOsInfo!.model, 'model'); + expect(deviceInfoPlugin.macOsInfo!.activeCPUs, 4); + expect(deviceInfoPlugin.macOsInfo!.memorySize, 16); + expect(deviceInfoPlugin.macOsInfo!.cpuFrequency, 2); + expect(deviceInfoPlugin.macOsInfo!.hostName, 'hostName'); + expect(deviceInfoPlugin.macOsInfo!.osRelease, 'osRelease'); + expect(deviceInfoPlugin.macOsInfo!.computerName, 'computerName'); + expect(deviceInfoPlugin.macOsInfo!.kernelVersion, 'kernelVersion'); + expect(deviceInfoPlugin.macOsInfo!.systemGUID, 'systemGUID'); + }); + + test('setMockMacosDeviceInfoMap with arguments', () async { + DeviceInfoPlugin.setMockMacosDeviceInfo( + arch: '', + model: '', + activeCPUs: 0, + memorySize: 0, + cpuFrequency: 0, + hostName: '', + osRelease: '', + computerName: '', + kernelVersion: '', + systemGUID: '', + majorVersion: 1, + minorVersion: 1, + patchVersion: 1, + ); + await deviceInfoPlugin.init(app); + + expect(deviceInfoPlugin.macOsInfo != null, isTrue); + expect(deviceInfoPlugin.macOsInfo!.data, + DeviceInfoPlugin.mockMacosDeviceInfoMap); + expect(deviceInfoPlugin.macOsInfo!.arch, ''); + expect(deviceInfoPlugin.macOsInfo!.model, ''); + expect(deviceInfoPlugin.macOsInfo!.activeCPUs, 0); + expect(deviceInfoPlugin.macOsInfo!.memorySize, 0); + expect(deviceInfoPlugin.macOsInfo!.cpuFrequency, 0); + expect(deviceInfoPlugin.macOsInfo!.hostName, ''); + expect(deviceInfoPlugin.macOsInfo!.osRelease, ''); + expect(deviceInfoPlugin.macOsInfo!.computerName, ''); + expect(deviceInfoPlugin.macOsInfo!.kernelVersion, ''); + expect(deviceInfoPlugin.macOsInfo!.systemGUID, ''); + expect(deviceInfoPlugin.macOsInfo!.majorVersion, 1); + expect(deviceInfoPlugin.macOsInfo!.minorVersion, 1); + expect(deviceInfoPlugin.macOsInfo!.patchVersion, 1); + }); + }); + + group('DeviceInfoPlugin.linuxInfo', () { + late App app; + late DeviceInfoPlugin deviceInfoPlugin; + setUp(() { + DeviceInfoLinuxPlatform.registerWith(); + app = createApp(); + deviceInfoPlugin = DeviceInfoPlugin(); + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + }); + + test('init', () async { + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.linuxInfo != null, isTrue); + }); + test('setMockLinuxDeviceInfo no arguments', () async { + DeviceInfoPlugin.setMockLinuxDeviceInfo(); + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.linuxInfo != null, isTrue); + expect(deviceInfoPlugin.linuxInfo!.name, 'name'); + expect(deviceInfoPlugin.linuxInfo!.version, 'version'); + expect(deviceInfoPlugin.linuxInfo!.id, 'id'); + expect(deviceInfoPlugin.linuxInfo!.idLike, ['idLike']); + expect(deviceInfoPlugin.linuxInfo!.versionCodename, 'versionCodename'); + expect(deviceInfoPlugin.linuxInfo!.versionId, 'versionId'); + expect(deviceInfoPlugin.linuxInfo!.prettyName, 'prettyName'); + expect(deviceInfoPlugin.linuxInfo!.buildId, 'buildId'); + expect(deviceInfoPlugin.linuxInfo!.variant, 'variant'); + expect(deviceInfoPlugin.linuxInfo!.variantId, 'variantId'); + expect(deviceInfoPlugin.linuxInfo!.machineId, 'machineId'); + }); + + test('setMockLinuxDeviceInfo with arguments', () async { + DeviceInfoPlugin.setMockLinuxDeviceInfo( + name: '', + version: '', + id: '', + idLike: [''], + versionCodename: '', + versionId: '', + prettyName: '', + buildId: '', + variant: '', + variantId: '', + machineId: '', + ); + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.linuxInfo != null, isTrue); + expect(deviceInfoPlugin.linuxInfo!.name, ''); + expect(deviceInfoPlugin.linuxInfo!.version, ''); + expect(deviceInfoPlugin.linuxInfo!.id, ''); + expect(deviceInfoPlugin.linuxInfo!.idLike, ['']); + expect(deviceInfoPlugin.linuxInfo!.versionCodename, ''); + expect(deviceInfoPlugin.linuxInfo!.versionId, ''); + expect(deviceInfoPlugin.linuxInfo!.prettyName, ''); + expect(deviceInfoPlugin.linuxInfo!.buildId, ''); + expect(deviceInfoPlugin.linuxInfo!.variant, ''); + expect(deviceInfoPlugin.linuxInfo!.variantId, ''); + expect(deviceInfoPlugin.linuxInfo!.machineId, ''); + }); + }); + + group('DeviceInfoPlugin.windowsInfo', () { + late App app; + late DeviceInfoPlugin deviceInfoPlugin; + setUp(() { + DeviceInfoWindowsPlatform.registerWith(); + app = createApp(); + deviceInfoPlugin = DeviceInfoPlugin(); + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + }); + + test('init', () async { + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.windowsInfo != null, isTrue); + }); + + test('setMockWindowsDeviceInfo no arguments', () async { + DeviceInfoPlugin.setMockWindowsDeviceInfo(); + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.windowsInfo != null, isTrue); + expect(deviceInfoPlugin.windowsInfo!.computerName, 'computerName'); + expect(deviceInfoPlugin.windowsInfo!.numberOfCores, 4); + expect(deviceInfoPlugin.windowsInfo!.systemMemoryInMegabytes, 16); + expect(deviceInfoPlugin.windowsInfo!.userName, 'userName'); + expect(deviceInfoPlugin.windowsInfo!.majorVersion, 10); + expect(deviceInfoPlugin.windowsInfo!.minorVersion, 0); + expect(deviceInfoPlugin.windowsInfo!.buildNumber, 10240); + expect(deviceInfoPlugin.windowsInfo!.platformId, 1); + expect(deviceInfoPlugin.windowsInfo!.csdVersion, 'csdVersion'); + expect(deviceInfoPlugin.windowsInfo!.servicePackMajor, 1); + expect(deviceInfoPlugin.windowsInfo!.servicePackMinor, 0); + expect(deviceInfoPlugin.windowsInfo!.suitMask, 1); + expect(deviceInfoPlugin.windowsInfo!.productType, 1); + expect(deviceInfoPlugin.windowsInfo!.reserved, 1); + expect(deviceInfoPlugin.windowsInfo!.buildLab, + '22000.co_release.210604-1628'); + expect(deviceInfoPlugin.windowsInfo!.buildLabEx, + '22000.1.amd64fre.co_release.210604-1628'); + expect(deviceInfoPlugin.windowsInfo!.digitalProductId, + Uint8List.fromList([])); + expect(deviceInfoPlugin.windowsInfo!.displayVersion, '21H2'); + expect(deviceInfoPlugin.windowsInfo!.editionId, 'Pro'); + expect(deviceInfoPlugin.windowsInfo!.installDate, DateTime(2022, 04, 02)); + expect(deviceInfoPlugin.windowsInfo!.productId, '00000-00000-0000-AAAAA'); + expect(deviceInfoPlugin.windowsInfo!.productName, 'Windows 10 Pro'); + expect(deviceInfoPlugin.windowsInfo!.registeredOwner, 'registeredOwner'); + expect(deviceInfoPlugin.windowsInfo!.releaseId, 'releaseId'); + expect(deviceInfoPlugin.windowsInfo!.deviceId, 'deviceId'); + }); + test('setMockWindowsDeviceInfo with arguments', () async { + DeviceInfoPlugin.setMockWindowsDeviceInfo( + computerName: '', + numberOfCores: 0, + systemMemoryInMegabytes: 0, + userName: '', + majorVersion: 0, + minorVersion: 1, + buildNumber: 0, + platformId: 0, + csdVersion: '', + servicePackMajor: 0, + servicePackMinor: 1, + suitMask: 0, + productType: 0, + reserved: 0, + buildLab: '', + buildLabEx: '', + digitalProductId: Uint8List.fromList([]), + displayVersion: '', + editionId: '', + installDate: DateTime(2023, 04, 02), + productId: '', + productName: '', + registeredOwner: '', + releaseId: '', + deviceId: '', + ); + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.windowsInfo != null, isTrue); + expect(deviceInfoPlugin.windowsInfo!.computerName, ''); + expect(deviceInfoPlugin.windowsInfo!.numberOfCores, 0); + expect(deviceInfoPlugin.windowsInfo!.systemMemoryInMegabytes, 0); + expect(deviceInfoPlugin.windowsInfo!.userName, ''); + expect(deviceInfoPlugin.windowsInfo!.majorVersion, 0); + expect(deviceInfoPlugin.windowsInfo!.minorVersion, 1); + expect(deviceInfoPlugin.windowsInfo!.buildNumber, 0); + expect(deviceInfoPlugin.windowsInfo!.platformId, 0); + expect(deviceInfoPlugin.windowsInfo!.csdVersion, ''); + expect(deviceInfoPlugin.windowsInfo!.servicePackMajor, 0); + expect(deviceInfoPlugin.windowsInfo!.servicePackMinor, 1); + expect(deviceInfoPlugin.windowsInfo!.suitMask, 0); + expect(deviceInfoPlugin.windowsInfo!.productType, 0); + expect(deviceInfoPlugin.windowsInfo!.reserved, 0); + expect(deviceInfoPlugin.windowsInfo!.buildLab, ''); + expect(deviceInfoPlugin.windowsInfo!.buildLabEx, ''); + expect(deviceInfoPlugin.windowsInfo!.digitalProductId, + Uint8List.fromList([])); + expect(deviceInfoPlugin.windowsInfo!.displayVersion, ''); + expect(deviceInfoPlugin.windowsInfo!.editionId, ''); + expect(deviceInfoPlugin.windowsInfo!.installDate, DateTime(2023, 04, 02)); + expect(deviceInfoPlugin.windowsInfo!.productId, ''); + expect(deviceInfoPlugin.windowsInfo!.productName, ''); + expect(deviceInfoPlugin.windowsInfo!.registeredOwner, ''); + expect(deviceInfoPlugin.windowsInfo!.releaseId, ''); + expect(deviceInfoPlugin.windowsInfo!.deviceId, ''); + }); + }); + + group('DeviceInfoPlugin.webInfo', () { + late App app; + late DeviceInfoPlugin deviceInfoPlugin; + setUp(() { + DeviceInfoWebPlatform.registerWith(); + app = createApp(); + deviceInfoPlugin = DeviceInfoPlugin(); + debugIsWeb = true; + }); + test('init', () async { + final bool tResult = await deviceInfoPlugin.init(app); + + expect(tResult, isTrue); + expect(deviceInfoPlugin.webInfo != null, isTrue); + expect(deviceInfoPlugin.webInfo!.data, + DeviceInfoPlugin.mockWebBrowserInfoMap); + }); + test('setMockWebBrowserInfoMap no arguments', () async { + DeviceInfoPlugin.setMockWebBrowserInfo(); + await deviceInfoPlugin.init(app); + + expect(deviceInfoPlugin.webInfo != null, isTrue); + expect(deviceInfoPlugin.webInfo!.data, + DeviceInfoPlugin.mockWebBrowserInfoMap); + }); + test('setMockWebBrowserInfoMap with arguments', () async { + DeviceInfoPlugin.setMockWebBrowserInfo( + browserName: 'safari', + appCodeName: 'appCodeName', + appName: 'appName', + appVersion: 'appVersion', + deviceMemory: 42, + language: 'language', + languages: ['en', 'es'], + platform: 'platform', + product: 'product', + productSub: 'productSub', + userAgent: 'Safari', + vendor: 'vendor', + vendorSub: 'vendorSub', + hardwareConcurrency: 2, + maxTouchPoints: 42, + ); + await deviceInfoPlugin.init(app); + + expect(deviceInfoPlugin.webInfo != null, isTrue); + expect(deviceInfoPlugin.webInfo!.data, + DeviceInfoPlugin.mockWebBrowserInfoMap); + }); + }); +} diff --git a/packages/patapata_core/test/error_test.dart b/packages/patapata_core/test/error_test.dart new file mode 100644 index 0000000..763daa8 --- /dev/null +++ b/packages/patapata_core/test/error_test.dart @@ -0,0 +1,434 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_core/src/exception.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +class _ErrorTestEnvironment extends Environment with ErrorEnvironment { + @override + Future Function(BuildContext context, PatapataException error)? + get errorDefaultShowDialog => (context, error) { + return PlatformDialog.show( + context: context, + title: 'custom:${error.localizedTitle}', + message: 'custom:${error.localizedMessage}', + actions: [ + PlatformDialogAction( + result: () {}, + isDefault: true, + text: 'custom:${error.localizedFix}', + ), + ], + ); + }; + + @override + Widget Function(PatapataException p1)? get errorDefaultWidget => (error) { + return Text('custom:${error.localizedMessage}'); + }; + + @override + Map? get errorReplacePrefixMap => {'test': 'CST'}; +} + +class _TestException extends PatapataException { + _TestException({ + super.app, + super.message, + super.original, + super.fingerprint, + super.localeTitleData, + super.localeMessageData, + super.localeFixData, + super.logLevel, + super.userLogLevel, + super.fix, + }); + + @override + String get defaultPrefix => 'TST'; + + @override + String get internalCode => '000'; + + @override + String get namespace => 'test'; +} + +class _TestExceptionWithFix extends _TestException { + _TestExceptionWithFix({ + required super.fix, + super.localeTitleData, + super.localeMessageData, + super.localeFixData, + }); +} + +class _TestInfoException extends _TestException { + _TestInfoException() + : super( + app: null, + message: null, + original: null, + fingerprint: null, + localeTitleData: null, + localeMessageData: null, + localeFixData: null, + logLevel: Level.INFO, + ); +} + +class _TestShoutException extends _TestException { + _TestShoutException() + : super( + app: null, + message: null, + original: null, + fingerprint: null, + localeTitleData: null, + localeMessageData: null, + localeFixData: null, + logLevel: Level.INFO, + userLogLevel: Level.SHOUT, + ); + + @override + String get defaultPrefix => 'TSS'; + + @override + String get internalCode => '111'; + + @override + String get namespace => 'test.shout'; +} + +class _TestPatapataCoreException extends PatapataCoreException { + _TestPatapataCoreException({required super.code}); +} + +void main() { + testWidgets( + 'Error test.', + (WidgetTester tTester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final App tApp = createApp(); + tApp.run(); + + await tApp.runProcess(() async { + await tTester.pumpAndSettle(); + + final tException = _TestException( + message: 'TestError', + original: 'OriginalError', + localeTitleData: {'data': '111'}, + localeMessageData: {'data': '222'}, + localeFixData: {'data': '333'}, + fingerprint: ['A', 'B', 'C'], + ); + expect(tException.code, 'TST000'); + expect(tException.message, 'TestError'); + expect(tException.original, 'OriginalError'); + expect(tException.logLevel, null); + expect(tException.userLogLevel, null); + expect( + tException.localizedTitle, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.title', + {'prefix': 'TST', 'data': '111'}, + ), + ); + expect( + tException.localizedMessage, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.message', + {'prefix': 'TST', 'data': '222'}, + ), + ); + expect( + tException.localizedFix, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.fix', + {'prefix': 'TST', 'data': '333'}, + ), + ); + expect(tException.hasFix, false); + expect(tException.fingerprint, ['A', 'B', 'C']); + + tException.showDialog(StandardMaterialApp.globalNavigatorContext!); + await tTester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(tException.localizedTitle), findsOneWidget); + expect(find.text(tException.localizedMessage), findsOneWidget); + expect(find.text('OK'), findsOneWidget); + await tTester.tap(find.text('OK')); + + await tTester.pumpWidget( + MaterialApp( + home: tException.widget, + ), + ); + expect(find.text(tException.localizedMessage), findsOneWidget); + + expect(tException.toString(), + '_TestException: code=TST000, message=TestError, original=OriginalError'); + + final tExceptionNoMessage = _TestException(); + expect(tExceptionNoMessage.toString(), + '_TestException: code=TST000, message=null'); + }); + + tApp.dispose(); + + debugDefaultTargetPlatformOverride = null; + }, + ); + + test( + 'Tests log level. if userLogLevel is null, then logLevel is the value of logLevel.', + () async { + final tExceptionA = _TestException(); + expect(tExceptionA.logLevel, null); + expect(tExceptionA.userLogLevel, null); + + final tExceptionB = _TestException( + logLevel: Level.FINE, + userLogLevel: Level.SHOUT, + ); + expect(tExceptionB.logLevel, Level.FINE); + expect(tExceptionB.userLogLevel, Level.SHOUT); + + final tExceptionC = _TestException( + userLogLevel: Level.INFO, + ); + expect(tExceptionC.logLevel, null); + expect(tExceptionC.userLogLevel, Level.INFO); + + final tExceptionD = _TestException( + logLevel: Level.SEVERE, + ); + expect(tExceptionD.logLevel, Level.SEVERE); + expect(tExceptionD.userLogLevel, Level.SEVERE); + }, + ); + + testWidgets('Error Fix test.', (WidgetTester tTester) async { + final App tApp = createApp(); + tApp.run(); + + bool tFixed = false; + + await tApp.runProcess(() async { + await tTester.pumpAndSettle(); + + final tException = _TestExceptionWithFix( + localeTitleData: {'data': '111'}, + localeMessageData: {'data': '222'}, + localeFixData: {'data': '333'}, + fix: () async { + tFixed = true; + }); + + expect( + tException.localizedFix, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.fix', + {'prefix': 'TST', 'data': '333'}, + ), + ); + expect(tException.hasFix, true); + expect(tFixed, false); + await tException.fix!(); + expect(tFixed, true); + }); + + tApp.dispose(); + }); + + testWidgets('Error Environment test.', (WidgetTester tTester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final App tApp = createApp( + environment: _ErrorTestEnvironment(), + ); + tApp.run(); + + await tApp.runProcess(() async { + await tTester.pumpAndSettle(); + + final tException = _TestException( + localeTitleData: {'data': '111'}, + localeMessageData: {'data': '222'}, + localeFixData: {'data': '333'}, + ); + expect(tException.code, 'CST000'); + expect( + tException.localizedTitle, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.title', + {'prefix': 'CST', 'data': '111'}, + ), + ); + expect( + tException.localizedMessage, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.message', + {'prefix': 'CST', 'data': '222'}, + ), + ); + expect( + tException.localizedFix, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.fix', + {'prefix': 'CST', 'data': '333'}, + ), + ); + + tException.showDialog(StandardMaterialApp.globalNavigatorContext!); + await tTester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('custom:${tException.localizedTitle}'), findsOneWidget); + expect( + find.text('custom:${tException.localizedMessage}'), findsOneWidget); + expect(find.text('custom:${tException.localizedFix}'), findsOneWidget); + await tTester.tap(find.text('custom:${tException.localizedFix}')); + + await tTester.pumpWidget( + MaterialApp( + home: tException.widget, + ), + ); + expect( + find.text('custom:${tException.localizedMessage}'), findsOneWidget); + }); + + tApp.dispose(); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Error log and navigate test.', (WidgetTester tTester) async { + final tLogger = Logger('errorTest'); + final App tApp = createApp( + environment: _ErrorTestEnvironment(), + ); + tApp.run(); + + await tApp.runProcess(() async { + await tTester.pumpAndSettle(); + + final tException = _TestInfoException(); + expect(tException.logLevel, Level.INFO); + expect(tException.userLogLevel, Level.INFO); + tLogger.info(tException.toString(), tException); + await tTester.pumpAndSettle(); + expect(find.text(tException.localizedMessage), findsNothing); + + final tShoutException = _TestShoutException(); + expect(tShoutException.logLevel, Level.INFO); + expect(tShoutException.userLogLevel, Level.SHOUT); + tLogger.info(tShoutException.toString(), tShoutException); + expect( + tShoutException.localizedMessage, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.shout.111.message', + {'prefix': 'TSS'}, + ), + ); + await tTester.pumpAndSettle(); + expect(find.text(tShoutException.localizedMessage), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets( + 'Error test.(no app zone)', + (WidgetTester tTester) async { + final App tApp = createApp(); + tApp.run(); + await tApp.runProcess(() async { + await tTester.pumpAndSettle(); + }); + + final tException = _TestException( + localeTitleData: {'data': '111'}, + localeMessageData: {'data': '222'}, + localeFixData: {'data': '333'}, + ); + expect( + tException.localizedTitle, + 'Error: TST000', + ); + expect( + tException.localizedMessage, + '_TestException: code=TST000', + ); + expect( + tException.localizedFix, + 'OK', + ); + + final tExceptionWithApp = _TestException( + app: tApp, + localeTitleData: {'data': '111'}, + localeMessageData: {'data': '222'}, + localeFixData: {'data': '333'}, + ); + expect( + tExceptionWithApp.localizedTitle, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.title', + {'prefix': 'TST', 'data': '111'}, + ), + ); + expect( + tExceptionWithApp.localizedMessage, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.message', + {'prefix': 'TST', 'data': '222'}, + ), + ); + expect( + tExceptionWithApp.localizedFix, + l( + StandardMaterialApp.globalNavigatorContext!, + 'errors.test.000.fix', + {'prefix': 'TST', 'data': '333'}, + ), + ); + + tApp.dispose(); + }, + ); + + test('TestPatapataCoreException test.', () { + final tException = _TestPatapataCoreException( + code: PatapataCoreExceptionCode.PPE101, + ); + + expect('patapata', tException.namespace); + expect('PPE', tException.prefix); + expect('PPE101', tException.code); + }); +} diff --git a/packages/patapata_core/test/goldens/ScreenLayout_BreakPoint_CopyTest.png b/packages/patapata_core/test/goldens/ScreenLayout_BreakPoint_CopyTest.png new file mode 100644 index 0000000000000000000000000000000000000000..368b48b7a196bfaac61818a4559b8db24c9028ee GIT binary patch literal 12641 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(1668.0, 2224.0).png b/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(1668.0, 2224.0).png new file mode 100644 index 0000000000000000000000000000000000000000..2372e90d5a443869068a9f9cd6369391091667d1 GIT binary patch literal 32355 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(1920.0, 1080.0).png b/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(1920.0, 1080.0).png new file mode 100644 index 0000000000000000000000000000000000000000..ae45c9cf28a9f4bfc46323d38cabdebbbffc99f6 GIT binary patch literal 23360 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 Nm|9&Z|I_Wk{{z6RfJ6WQ literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(375.0, 667.0).png b/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(375.0, 667.0).png new file mode 100644 index 0000000000000000000000000000000000000000..882aa93de2c095e7c2908f2bf7fb1682461d603e GIT binary patch literal 7127 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 D6Q`7S literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(375.0, 812.0).png b/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(375.0, 812.0).png new file mode 100644 index 0000000000000000000000000000000000000000..2d85678b6f8e6d266e96f6a927ae13d42e4d6496 GIT binary patch literal 7721 zcmeHM`B#$p{&&VH=iW+Zrc;g!c_*E$F~dYtL}c#NNTx|Mmgb6m-E+Y$5dpc?O{Xa} zH{4++O>dJsZm2*`B8c#%pYES<@42VvJj?lf&hvhr^M1cyuh(aJ zKfLA*Kk`@8zv}7f9r3t)=|?@ikI;I0`e#2qsOxDuzO<}+e3bGd{5!oyrnyMh_$1{! zkL#c64&0~VKk4ZK*dCYuem%W%a{*H%hzb^Nv1si;+I5Mj1|m9z{oAFJU-muu=l2(! zK6{YVmt=Bjpjsae2e8Zy5@9ALKZJ+6Uw8f3@Y7GnKl#jS^psWplh3|z`3hU75U+{X z8fjwn4_DDHv>VSJ{S&vo?P4vgNm$4b7p|-XBVYt@$=A%Ci z_s?~=c5<*2%RfOXUuquu(t9z|ngNXH?My+-AK=o@i;o!Avj91c*3jH|lm7x#+%2>( zbI)R@jY^Dam(Nrl;ZJH(5qjy7+~QoIVEMb0lOY7VSXG`{V9$T3 z`(4h!{(kOr_q4~3Qi+H3pT~BE9pez%b(X*bx%Bt#JT12lB65kqNx{!U`wF%0Akb*+Y-= z2@xFg<~er zrKtW>hWl%~YFd?&Vphk$zECMP8Qb(j1?6`xG)tSCEye;-H_L1oy{YbDOyecPm4!zG zQmJ^ze}A-Uc}y+31`lCW+Ze}*hOmlLmWA2cfvjQ7~ zix~^e(fu+dxf3`94C?vDXd|i7t;JT~{GFeEwUWNMpp@=z%-||cnJKGj0Nv$-xuv)i z!kroYK6p%S{){3~2O&x5we;iX)LP6vx=432FFJ;PLOe-e|J;9yyr@IbNO5_ zP1|f+XPv<*$5W#@gZ0SevhU{aT6rKWm9rD$y|=M`7rb1Rut~myeQ||*ZL%-i()jG{ zQ*ba}BXrtcMI#-Hha4t|uly7YG`AJE%X@%OZ5OT(8y2V$>+)$9M;C#msXf_KpRAMs zemXG?v06kwin<78KZGQ{{gzVvf|#=^;C9R2u53-ytkWdvHj1>|@V*zF5i86CCWMu@ zZhOKDsjDy?96Q}~oAq)z_lL|y@f#%@a?pG2PGL=3qI#P(%y2+Xr&IuQV_Fwx_li~ReW8oEYr94GWGEYvybt%8ryQ8b2p2zh^fKv zzD34|AHWnzR zvL%5fRbI4DsTg!UDSnw$Mf{^Oe?JD+%t%f`SdyLVph94#RJh3PHxJp9B7$sk`+HC&Qi+lp~E$y~Zy z>TYj0o^Oi`v9F%-?@V+R8qKD_KG#_X1qS#Rv^>sfldk49TnP+pO&Gew{IhDffb<+k zJ5P$Y%!%Vq6+f)1;(#9+0cnDJ!TjILX1)@??0kL66tmYbV(-B7>M!J4Ks52&kxTV& z!Z2(qWv`iy6SN(EXPY$E@x5||wx8m%s*~Z7%3Qy)F8vg7pL{jgiKSkh25pHwJ6Dzj zcO)2>nb&|)AMQjkVu4%IwCSvjFGZ;uB=aN-a0;t3$H_v+^VQL7bF#z?DLv45lv65U z{GSh9`}kL|8H)ZHI@H_iS-!zU1%l5jt^&C>k*qv>tSN0+d$WT^ND-MH>Z;w7j!~nt zW-9(jTrwPe2%jhfSjH>YA(8ak4E5x`4%5X5XyTtaGXiKK=2aP1+VLo@rVcL_|KY;d z4XzVu7G0n8O`+2w!n2~L2|E38b(zex+kmwtpfaWN3z%OKWaeLz47`WKO)+*f4s334 zKrE^lX9X#rHc*p%5RD77B}jT=6c5)IN@Q-~1B8(GZ(E9nE}1IT3jhu*X-Pj_(u4DH z;TFWdil-{KjUsPIUi;XMDB<&Wt>aH`a`Ks8UZ*Um>#wn!d{?FPEiV@*FE%6JGhrf` zRDHzobMU#MUdZY%F~ly1JZx&sIjRw_(F}$YRa$K>UHQka`U0pyfB?(fWy4Rzlh4g!Ug) zZSiv}-#)ZSfi1lEyAfZnXl~LjQ+s3;#wgZzHan<8?_CzRMABRFs+=jm`h^mHZ#tIg z@4Dl_`g!2J$2*VP1p_QzRi`V~F6!JD+bH;ZlXA`0`-~zK4$g+~ul~LE#)|D&L1>}A zGP^>;tmZ0e7(Jk<#*hLhhU$QSN3-HJOzrH)@BWHR+obod3z1N3nTp0G3Vt)#lg-k1 z>es3UJ-xXl{7lV`KQeVgBY$S;<6o%61AT4KrEs_81%MJs?m82;T&Uy;tIA~4=UBF8 z7gqgv*c%r%=OHgzE+p^^4LUc<3o*1mKy|AK$(MsalOC$&nEPOSU%E!B8$>mPAkcb? z`^0GC+-i6TE8JIXhj8tR@Y9I9At@pXYmghFczQn%#d9n|Vk9?oCvp_Rleg-+_!Jl# z+V(YMXM;8IfJRMR`0cRV0%Kc7%Nx0D=|M1LSg&0SG@#sC-Z^|EFG8@Sah23hiNwxw z5aAB%sd_z(rhFygl{;1S>@6;ny9j^J-0Hr8nc4J|PCrUJfm1hD1yk`@^g(z9X8LJM zP|}*l(pTXVW1e=GveVXjvN{_oR9&TU>}Jb`p08BiZ3*hg&_2{0bF2OdGI|sZYqj-| zW{r?iW|ve;kClIa|2Wt|QDa5IAZpK7N(m`5f_YLK|Wi}tW zCiPJW_x<9A@8z13n~$MOt+U{D%bi`4!HU>=^HM|kB0WEj<5*+^OS{NK_MC)m6+CK9 znEFqO~|TKZR5WlQ2&Ba5yPHkRS?MCZX13z%*~wWD4D zx1(Iigf^gd*>glzO9-iAX%}A^_`Qpd0i=Xz1*&45A9&2bp$>s#)Zu5G%WfF$z21Fd zgfgeXcLiE9)m8oss`JX*zx}L7>#gNnOS+;xyEF0@%I~=1j?jW3-m-v**Z+R^!TdRDn*;ge;gw5apD(({D5OX?IK$ExcCv9JC$!tVq8FFZp$bU zGuIM&6fIUmRL_BAEs42-`Ta;owza8pEm!P{9g?RF?}-B{g-u^Fi-B|HvV-oF=9F)m zf*DQ{bz_6FU(D12(K_>D`Z9e-&Azy=d=MoWCUl3|z1Ta|`WyNr3bWJLtGw5TQ)>=z z90S4_A@jFLH}~q33s8|&Zfp=gTk z%Fz~?mbuRkVJW)Io#44p%z{xS=ED!xeNSxd9%6!ZIDOY4N(xC5N4r!ss0SVE1pQZ` z94FqWZD5bApG7KfcomNsp%_q5y=At0T-nkTob(TjD29y{C^lSnaxT(E^gfX=D5Ge9 z+s1pL#EObNxa@j#mUtKQwAelKPdEcOgHx}mw+2wI8`vi7_yNq=A*M$`TW4p)Z+GO& z**Wf(Iqu7)O-WP6lA-q!vI$d8SPe~1w&b0^V9Jgid4q50T|Wkq!Wx4sXOJm#eG)}m zr_>n8DH5JD8+!Lz9_xJ~q6_6+hpEKiE%BnUa2KOEu9p`;IltPMoeO1x9(KKQ9lty= zHB*6BuXxIe_nZXdo7jol?a92kW0>hPo`!BZb?}r?2@GJMfg<9~(nXYuTQ?pp=C^@h zIOX>q1rKO!#SaTk_C&h=3pH25uLNLg{oX`Xbvq#En$N@1-lVX{GoxQDBPBRHk7QMh zjiFmdCg&3U-F+kBTn-Hrje;NSwoVb9*-Ht~%0qAntc}p<@#?|-LMvRu@zGU<+b@VK^s5l*^9sq(8BgO?2z_tK$`ZUcDT1nv zkXZORF#2km;Spg6)@k*bG1SIGN@?0#%AX?n)bQ*J)iVTjglxOcr zV@o!!4CFhNWVqjM3mDjy89%Y&K&FR{oxDkMXCOfJqiGm|Uw6u>7E-~+kNQ~Ub;byJ zY973Cq?&64K;W1sPDb)JLl-qEF5lHS)5l-U`Hnm0BKR zdiw4*wfg*?5ek~$_3AIzAMV{OH@Si(hOf>b`K__1d$O`9$-ij-Wf6L%VC&(KZa}oN zIj#UaRvm@*#-rW)`zevir}ESjbcYiz&3E6uHi7|IGii4s!k^}+9M5|u`BFGx%^tj5 zE*iRC4z^F(>#v}q(NW!V0bo?9{IsPj^fiEV^2QPq^eX7h!Vj1K;~P=`8o%8hfKXU1 zaycv4?VU5E+KR(o8{QO0M+4B))|QUOjrbj1#+MAQJ^8QLHv9J+>qC9xLw(~zed9ka hApd(H{8Ovn{p}00o5_?CUAb4!<9qK*jo*dd`+vA%1AGG;Ik2vZV5AR#1#8^0g-{=dJzbyweY);a5(ciiuJ_OthX zcj`|bE{6^rJ)ohXame+`rE3}*`v@8upM3cIGxe7W;+H1s$3E0GmkS#8v}4Qamw%ux zxc=AY>XP_*$iFl+jCEZv{rF$;cmWIk>?SOUFXjktvaW@5EGWfkz1BCht*0W+PThZS z;0vHZPW@monM)wdkh%f9Cpx%V>PvGj+$mt7E9OCS?j%7^gr13VrtMC19Du`ITRMh2iy z^ZY69zUR5?8b4i)*ZBSfL?ibrBaN&3bu?c7rgi_?-_?I_%inPLTMhoN>`VYzSfJi)-= z;Nh^^q-5c;pJVWR(Ou2|P=*?@msOY-1I-@79hW4tWBE7Xmh2xYYZk5`HG0Zc+}siv zL-{RLpm|>$*Mc%c;%hPR2snL=Q6Nk^-fOuKv?kVQrYX~e3dMPx&Qdgq{F*Ci$?C!1bjfM#pEGF|xF z5IvYFm=V{50`C~gc{1=u53Q28m#vCvg@$?hHCYPOYww>C0$5-`slpXqKQmrrYS)T2 ztpa>aZ7w*xNX9pUQT}F!!l&e?GgH~&E`<0O3%|CdLaGoD{T3)s*_LW?*Zz#N zIWY0v`lhx=ddgNeLQbWhNy-E!5*Q zb0W)W89`0YOugYUpwoE=5w$j|^9JpLVoSEF;ca58rrmGK5+H^OKgp{Uf7JPyJ*@EMCh+#ajyvlMi%_{WzXFBnS zSx@MD)@D$+<7RjDsir!3=>(reYIbN)t*k^Wm9RU`@0LR`g2@|vlXd`pVl@-prbiDX4P3cJG z1}xZ$&bdcTOS``8ncy$!g$sXnAG8xdF|jbO{H?YHYU#4$2RTNNBcE%K@hUR1YN2~S z4!$9`pKDOQ#cgUED}VnSRMv&~?&-{6^P6A@0Nm(=3~ZO2Qq&d!#~qzG0(;oNK=T_7 z+wa&Qq!rlgsQihlNPcvOyM8Iz7Lp~b6vyD-=W@4h7A1~|32d&jYGbxU&H*@WA80w$ zT_=Cp&_EC`DV6&*JA&jvgs#Lhd`rWt3acA}s6#4nAkB0Mk zTUAowLAo%jk|^HL4YROU3l1W8M;4&4$)CB9@0$p9pFWo4*HGrvGzBG{<0kIDH;mrN@u@>YzBKyqhuHsSFosUl z1ZdcM;rN-M!r?al28)8N_s6MqRpgo}@5|GaZ`2r#FzhlG+zZwcir%Lfn z0mU_Y;1c6&PuxIu<>-Md3K=P0n-~SDy=jUKZIuj>!ugF@==UuGHMl+rAOU4{xfOf4m;}F%1mtvU4z|A0N)aI`(6L-SBGrxTET!dz}(7i?`OBB@QHU2 zZns{)$uh$N+ABw${S-&xBKTFwPbQfG6$#2*wmZ#X^GGZiOUp28|8ejvcVEU2!$3S} zthjzK0JFm~FP4~v7mulU-VRk~vsw;YF-FI7^UJDn=_Mq^vjJQ5^0Olmp1-O$u|(?5 zGguzRX;+4d>M7I5(-XOy`L!iZ7ma~ME!tKL;P_C8tgNmAD^JxTPZ*AwsD%#qeJmMS zqihT^?KT0EExdM*h^rFq<7JUX5g|>2clj4e$;zJDvuBd}G>^TKbAgONM=R`PFZ~2ju;N{5X-*zak@ka}D4N@Lrngo$g zNmB#JSm4DcuP-u~xHJ}I7lk2oF~3?db5om`8qoamNYF&c83$;cU6QYx)FPZ(XOf;6 z$G;RQDjw-spU20~T(S4d$p`CIp%Zt)TLY7#MI*Z!gX#byWB);5r|gE-L%9;+2I}kv zH3UHNF*oO@C(h!IL+7WhAVp>3ki?b~CbKhR?PaUa9D^}@pJXnNRJEvOoAJ3$lzT@U zpP9HhQC;0^97%Yfn_@JNo)F+h#TnjeY!0T~gi{ry_N7w>inYJ`e3OX9dmIZA4W-NGB zPls=OnvK6MjS0g$7zIrnz3OEh=HZCB$p4m%{!tM>Vd=PCR4Gc9W(25khh9kj**K^< z;wP&#M&;c3UKjK<>LnN$s<}u0CRko{KOXgV~)s6ApP+?SN;OIg?%| z3w22<8J(47brNDr#h~0Xq@MnOO&E!tiVxQ%5)1{q1PN?+}@m+e- z!2{VmPRd3z61zUpj9#!C4j7mGa#CF>;I@QXrJK6${ba(K+1_^hgYILCcd|ud-&fu% zRCD%Swew|}IXx>^B{?aypOLYt^Y+aT)XY%l0H-L^!qmE$uoJzNl<7p3jut?2TO}?B z+B5nW{&DTYAFbb%`#!5Szl;nGG%QPu3pDIuvD9;vnMW|CI)+U+Rrpaj}nzj51vTU zjhQ($$S#0L@&4&y%*d8bBz+5w7(tbi?DZ{eW1ZV1O@~mDi5l;asE8kIODuCb?gV?Q zR2iSj=MT0m*wB{jcxTa-mR$_oIRkf8Z4dZsHDH)eNYOW~MN>I@n{%<4U3zJWslic$ z;lRXYY{%{0hw`=e?ThL9_23t?Z(^UHAWH-ba3o=q*LW!jovRUAXMBnXCPb--ZJD<_vOgoxD&p*}wrnINiGs z5>>P9q5W;FAAP%`0SOj7eWTct7e&*sVsYbH1 zYa+XCH2cea&!9afES700$d1Hu^GI50k_xOvOo^ZiYe(KC zr_2Cjk31xmWf3JNfy0xL0i_SPqxjC~pHH4V89lq}FR3Rh5+Z%}K9KII*W{LsCnv7o zr~80YOozdP(F$61&23q_^}>-ryR#{%*}jj$fz2OKzwD9Z6piG`99IYi9?pM0(b zHA*kkR06_vU^Ww?y^+F^M}hXIyb&wz=-Erjk(8g>v|D%}Cn4=AIq^>1Asf%ty{(AQ zOs&G7F9v&&>qG{&+yA^SVh?IPaY6Cx5%QEtR$d2cW&=xBzQ8BF{oRyODZp;Hl&Wsj zccU)WccFq$+XhofvP^-Hvfnr6U87U-U)1T&8UP)F!KnBE^7pI~FYcQ)g%iz%6nD{4 ziJXM7PMI@CC$f9R)toN$VVkNhLC>%#;`lGqvTd!%Tlg;Q%mzO6qI~LdadQl9oW3nD zjo~9Wz65k&vxR=$3I~Z_FmPNtOW=x~*-}AusU*b#>%tbJz&WOx!BDx#yJ0BQuyf8^V>uQL+NPbRTqD^8-SuxBr5@#%`0iZ_I?D{ zs}n2?rvYkt{*ACceN43`VyVvO$DkK=k{3=WUgVfI*oxr))+mCijM)ueIsB6D=Xj*Y zljE>=|DFmi3eIHSS5tS9!8^&k{m zna29qq{LFUV{?vb7tD=1%{hark!CondF>COumkhg!HLkZ3FFQy_niRS;>$)G@o{}~Ev?6F zug6L$!#SohWXVxcHs~=*ZT*y6bq6H{?G|qrRrzaQ-=%Rljo*lN*yB>=B&h1vEQpD3 z*3&WG?5h9-*lmHBt*c_>HJOjjmM7GzguK>sie&mxaW*ro7kWv-=nE@TyOjGTcYqX( zKKXm7Zm@i&70oL#main!1J~2eVfr6=Jw2RHE#09edvkueVS&f?_;f!s@of#+bI2|w z5OtI=Nsn;F)wHe{<7V`8X=meK!^jWnIO7M^OZl%u-@wy&a<@bBP}o|59|T z9s=UyIls3F+fpNpA=~!~N-lb30#@RcHKCa=v%o+=II!>>swy&~9kKKwkPJ35K++nq zTL|tx!dGNv6MykK{Z_Hzgi|4Rw6UeVjfeO0Ud%mmP}UtL*zYxXxBkuYAV%U=+R9Oo zV~4v9jE)bx)>P`98sQkGJt*5nB9n%@bwbrmN8@K+{UsmW^+$2?Zz_^JvIC^MT{)&T zHyk?At zH1v^p^Mu#z`zCZ*fBaEJhFH7OP8|XJ;G`vmJJnk(%&>2l)Nr*-l^;O57fB#RnWir~ zXI!tGxnDK%>Ms_CC83WP0s;yq9ri{v??kB?8#UMTshn{R%>#L}aELC&m>L`noOrZq zC9r|o*MZuo<(Cwj+J(x;v4F1SL51`dDTaF7;A&M7Nwm%UTxN>;1Bj5o>P&^@8D*zr3=RXS!+?WsoM=`ze{M!(ezWL#&iecWvab z5SZQ28Z{Y8wbRZm%oc+sZh?U%y^YT79cHlNDl?rzajlxT(8$iB1d!ojwD(I9X^V1j zeNk3pSf8|I;X%ye_$KVObLsk)j%<@Z@6E=Cite@}wnUvWT3p3aDuAKIaZkU?Jq; zCFL^osbq9kKkOIfs6%lMKYcL;-(_A!U<^%9HzSL-v#kH;bV1k1_LGc-_05+joLs#< zJOk}PyQTMBMCT&|$fuytbiAzPx~~<|<0NA!K6_&D*mTEY#-Zugh#&MUsZh*&6oCY= z&umro3Oa)O`|aSQJlyA`gDhc|#pR z7mWdnvaAI9si{^?mI#H%HwgMAJs9tP<7Wf7=&ec`h{?1hXe@Ni2>}iI?#YX79e0o`(F4u6q>~X37LeQ`O4PW=t Ae*gdg literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(414.0, 896.0).png b/packages/patapata_core/test/goldens/ScreenLayout_ChangeBreakPoint_Size(414.0, 896.0).png new file mode 100644 index 0000000000000000000000000000000000000000..8b4f6f3129b1519c68e78dbac570793a2733b499 GIT binary patch literal 8673 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(1668.0, 2388.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(1668.0, 2388.0).png new file mode 100644 index 0000000000000000000000000000000000000000..3e97d25155f1ad54511406a8e1b63bc55a4bc03c GIT binary patch literal 31921 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 Tf}g+R`-Yi`)z!)?u7CX>s?Da~ literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(1920.0, 1080.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(1920.0, 1080.0).png new file mode 100644 index 0000000000000000000000000000000000000000..cf9adb2b4dcdea21dc43473903b0bd7f836c139d GIT binary patch literal 21495 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 C&kGs= literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(375.0, 667.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(375.0, 667.0).png new file mode 100644 index 0000000000000000000000000000000000000000..368b48b7a196bfaac61818a4559b8db24c9028ee GIT binary patch literal 12641 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(375.0, 812.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(375.0, 812.0).png new file mode 100644 index 0000000000000000000000000000000000000000..9823c1d942ada5e054b498a790b947a04596ebfa GIT binary patch literal 13223 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 U&Q%4AjZ- zfgn;sg7kzE4BYts0r$su=Fa_Q<};a3vb$&Z?6c=Qd-m)nBY-vo{T+G=3JM0DXBs9H z6qLCX6qkNop&|cL)XL05{-X3V(N?2?k8xnhFPHt)bj+@hKcQD%eWakcN1>ykY8IHi zy%Z2?8vwc3KVCX^XBtitwnqE6-cT3=srjp2x~%pvWilnP!RYI{wwsPX?H{!T$*Zi7 zgrc6hPQB-S#SeI<_Q6r%)3z*HJ!DA;yK{D?UIT|03)wyga#tY__XoP~1aQ0u*&m0D z9fv}BQgw?jQ@mxyyz-`?aPYscNI_xxo|eKPW{-kGpP7r|tw0wQg}7!Q1x3jfCW;@d zE0h!&5B@9kp9=m%!T-Yx!PoGJCT=aFZGRB`{{EAaql#9@@+)r|ia2+}+2C!R6u^=5 zwa$8c}b4JaXbe?d~U^R zzCzOXQFJ4h;{qvkstyDnmKONp%Kk3EZ)}?-F02n?&wb=(gwnfw-J)aI)(x)?#B?w| zHOarB-hMO5I>fAOThKVRl?Scz*cGFy0oXQUZdrOOe1+*A%<_BXa_j(%BsgJ_T4t=w z7fq*o>=RN|=P^MHv^skhzC8t8W;OF1oJclQSTinF6jX|6TG#Vl%uink zm7m(SaICgg@+sXoVJXM+*VPCY^ztP)dS)nHVR|?!qsB$jLvHfBj`@_-_2f+52r$vJ zAzdI$t#$zA;^W4GW@?mtHGrG90FRQ;A9F@U+b$YzK(-C;SCwUnyp8qLk)OE%-pqXA zrP7}{WfGvfrKFSdN5?%<29~_&XpP$l^kbVmm(Y)7)RNpnvijo0!c8m1IwXn=VkNb& z8_Uh|=DD+SA)jpETom*OsTiTzJ7Xe6*4B`P1>7_yFQ+g7{%2;n(gWG`QcHC}tSeH- z4{{H_j5&FiG5YTArsaf$r2ESySraIJ`JG4&A@JaTGPjTL^@a%X%8t%~L$rhwS z;h+hev7KS_R_AczshZ4XUxff?;b4;~?m_@WVQtc2+b##qI&tvft@6am>~6OxVPh{? z1(=wfC&tRDY_wm5-*1VjYvGfE)5*=;vmb-1SU@=iCY%E1phEJIq{C@*;=t-`P>}G` z2Z|Z5mPMer8_PZ1daefYcoikDw%XBBL?JaIf3%bv+yds62z|UEm~`lKYxhcILvDsT zcXOfTX#R*wJwe zLjDdm2SG+UxnwQN?(S5W0u%Z--o8Fz=qfr?C_)MHe32QtdZIDU<%bheg+6&q0}6_@ zzgSwec(iU*)pfA;#(>M4QCs)8z6BpeE6L8_Ipi4FPE;#yKUvIfRkC~XPbcdaz-n7g zVXF5m+JWMHX7Z+pHpzmQ{cCnlw=>I3!;veqWG$noccyptHVe7z6FOVHOMw@p%J5%@ zO&o=GUiaDUmre4m8_eR)e0=L0hYJtG8;veb`kURMYXRw8&a_>T0*+A%Fnf!QFr)AX znkG{hM+kiTlXSS}N8Fh$9cKbmC1x2?^VB+_DEgVB(sM%K@`43C9Fp(bngv!n57CAJ zIy@84{r&2_P__jnirm1ikzSCyz5pgLnHUyiCKK;lP*^-x1hRgVk5Z$nI+g=FMVMl! zr*`LuE;Z8%WG1!a9rD8naDWbk{d4T#{c$?|s`GszCt_DEOGk(&?~+7m7{UG1zlRt0 zBgd>*B_H?hz2X?lRl;5NC_LI+e>r#X3R6k82bGUBUy-4|LCy(BEnm`1+A|Zj9LBc% z37F8;oG;do8$L0Iplpb9EY!p-zhGV+&x7*dYtxN<*oGxO#3`&?VyR!va1oaK*_2j& z9#~`vygk|4)iea5GpD{Gxc|^>N3Xmq-3TU@?{1-=u}_v*#KhV=n+Dpv%hUSSprqlI zt_ssb{Go$dDI4E9{6G+$9r7srJlh^4n&3`qZIWNRZ5p_vU{uCrSa|4ek){dm?fN`c zj-9>j7s7U{d$2VlliX&dVOq~juJ305lIa-|Kd+${#a<^cxnaC}$fsKe4L*4j;T8G@muFa8CR@@dvOATj9dP4A`ooS1A?F4d z0d+0zw+kC{Ta^Q`ZK7FsRS;2R-kywz4)s1%uD%mFc7NXu+63x`fZelRw%oc1Q?KA^ zckcjXI{F#hA5nZyJ3SUKR1kdpI1b8tiljCN8CPUSj+yK57NZ)7Qt;qAI)r1SnzUOvHdCJ z@EbsOlv@ExEaQn?Kl80RlFO!@8cW*{*oC5Z!f1|(fD+C+JVv==$i>vc&8;^qr`wSBjk!(@oB*hYYF$z*DpyGSCY+7noQPc+~{+N%jCK0OFcN7w#<348be=v`*+dr#cAFExdKQ=f-Zv0E>}QaXpmWvX^E z_AnH@U6HpPa=WamzS2t{bY?fE$rHAnL{CXtchh*Z)ei8oYrAiTrl(a_y~G1dJ{5P; zEqyTYHX-w#FB2Gwg!_sa%$IaGd8zB4w(;1hA<(N62pEqEdKIE0qA$5dl8an-Qt5f!-Js0pZezm*inKrr!8Myl9hS5=)&V z=#vc0KzhD9iGJEtmgUv z>A{Mdr>xu{jSHe=DO}7Cw*xQo@&uGmyVKNY`)+4uL;_sQq~h-!U155|0Zl45wFJ{w znA=GYm+q*>wzhrmfyt!F65Jmb@5=Di*9sNSXfWL2ND6m}dNBEEUe4#{N+<6<#UED$ z+02UzVg_J_Mp{g?V^fHG!;k{EHUY=d)gK=1pB;5>`?EGk+%sB39{j? z#G#HGl)g-LcNY2MOJlBi&x7$>f`s{VcM#FQFEQk zA-h!<*Q-^1zJDGQQOy=?YGuO=bW$~0k0=^D<~L`4RxbQGrTacODhEfefPeWQKS8un zJ}cDy52#|)H4DSu`;QMy)>4vLfH=T_4tjq6y*#cOtj)QA7MI`;-RRZ^H0W}Ovtsrs zRwsng+t1aC8KX&Im@aUmK z8js=+x@SZAK=ZO)Wx2KjKUG^O+@|L{r@m+xRrcF9!HJDFWu@iE)E3O6#t{mzYA^RM zM;pDqiyEE7&Dd760gg7-)|4vb@zogdObXL+yxbN; z25-(mnJPp`$NvpdB5r}ViN6Y^-5H?gHb`Lx+;pi-h2ZCmiS0`dFOh5bJ*Ctnjz8Xvm&3=> z*+38z$ulZ>a|Q9{FKqpGL&2K0+|zPS084Kx)+uSdcjY3AogVH~rW7lbG}t8TD&5@; z#06iq`~wz0gtdXHO-C^41)(SBd0}mXWI;vxWx__U>MZl!k?ZeiH`70>htO`n+)9wg z$8Kw~9N1J0@gN8_Jh%h}&{7+ip5aTI#fIJ{!b!EwL$fq3U6X(9G-6OA@NL+c+1J2} z88;n7KnrlmF=H&d!p|cFzZwfIA4s0z3z%WjDDBCbQORB?SFxTH=nzUh#!#2gcTqia zPX==88m2R9auNx{r`+2%cHC1?F=et}{9EgXH?dwOgo-|&sH*mKH!+}WnHE1JdyrrW zIq=iWtKrIfCg<7;n9;4Gj*k*UnH34QhJaQwn80p0Yds5)=D%*0!=dYoDx{GPVfDt*x%xDj)isr8Sk zp<;Mp4%uvyeouANH%<3@!tEA{h1eyAI{)-IF^gAh@7hy7;DIPC-NFhN)e?*s>3zU8faQ zr!D9)sHl9oZvoXcRk9D@REaf6@_*b^FJmZV6i&q7tjg<__IbNlY&r1eXP z-TwOe{&J0MS%=l|tV+b|D@@KgRNeZIT(SPRPRMGW*&CA}v*nhrFBYLxBd^H)(T#1T z5;9~u+Dn+M4ww2OX|LANir;JErp>^T>&6#x$SaO~=hQ*?Ay2dw8naOY>-29q$P(|w zA2?PB3p_STos8i41x3|LFY@n1Q z56LpsB13O)edlC}k;ng#dH{(EKG&|?hiO8 zWNW4MPy)!bId1MzUQ#y)DzS*)USWUT2i=H>WGjha(FhcGw&9#~kzN3Nqp} ziZy6&$&lSOCe=Uhh}JL9^3AGF4smL}QlDb&>Ply&-}w0=T+-^d0Zz|F28-kCq(eXW zcj}V?4(kZVFTG|T-JR>mL6wzLV6gu~`YImdn+PV>N6A_hm*~jaac*;xDCBS`AgfN$ zP{!H*0mfEHfiO62l~GTlFHV-cj_n%(qc+(32s_F01UOqx`ChFx!?&k_;uU|ef2r~nB!vU7{%ww{New84E0-)OTVdG z#CBnMCch-%b1PSR`EX2~Z&+%~CA1Ez1Y@UE353?+>ej(lPpW;V8XSdly^lN$)3vn` zN?%r?9>zNQS?Ve|-gC9tc&NFj@9fm#JUo$a;WU+z$ zK&NiQ;1vNq{l+R-q4kCD7Ee;qSuU!II~?ymjp2@^QiZnHSJ?#o5wQS*PB>YC!q>23 zNYLhpJL*dPP(>up8YC^8p7wD)`t#kc$X3+Bf-#vo@XyS$+}`(ZVQh3ouH0NYona(( z6ELun(AVjO%EsELsdUB7D}-|CpM^ueFC}BRV!gdYknDL`Qn>A%dHbS}BX;a)tU9qz zE?yA-vl7n9Rae@sEt-t!8F?D`8EAd{WV5y-@X@LVwK7yx(P{7pyuR<~GuJ#K;MdvN z9TZ`o8(k58u_aU9`smE*s&lfKiajhm{QHAd4??d*8`#Ha=Nx&)f9Fzg`F+wM!p}SL zORz0*i7i@KhV7OVc@%UDap>F6v9_x;^Al@n7Y9rHRz)w8M#dtbVhZ3)fW5H{4&v2{ zH&Z%2pp*_c;>tt#FkrAoHF z7*i_Dd6rA9`MvNLYEDcqs-d+ZF@$ee?J8C95>2ISbi4$lct~=f{8D(%-es^PA0# z3QX2x;Iq{HVq?KsXG|rH%_Hd(cW*MP@VpstWbXr$IU$$aqKwY(PW7ng-F{mYe8jmq z`fcpcO%%O3k%c%cy^U})hz^A>mgvy>1d7JM&eWAnEHuD(=A1!GON z(?q|;sozMa0MT0#^8JebMNhrkL&5-3=i<_2(Aj~sr~3WrA&w#W<-()cLyJ@Mg_^V2 zC6i;74(sLSEo>YryP*J(?qykW>AJsD2L;8A!GE~`_RCjY9krhY{M~2ce7%!06Tmge zet}_LI!N~(2&`NgVQ8!<`;kXDW7N^Sg9zbgO~B=a`F1eYgBU}rDj$6n0$9Um34VM! zpI(_jJyE66c8gMPve@ku%DvJ1Dav6>TOX92g-Leb0^_C&JZQJyxkH;+0kUh{kF8F( zwB7L?A=6@il!3bB9r;vhteUUFnOEbfYeX2L(5z~uo%xqXjvZ;9djp$a!C3Yq2NIH< zWZ}9t`TXgp7lx8cbGT{g;~5x#?)h?i$%BHdIgo4EMe56z0#VW{Z5WqQ_POo9fcNap z9s+xXyjG~)|A3WwuN12HDnusF;Bnfi=+7;k-J^w|GCm%8^WrFC4+Dlt&aTE{77<7? zQEFP3hHQ4Ao3uYgzcr&gO(=ug`s^@7cK+z_RTz zqIlEzc~ThgtWZT!X3xjmEx(|?*o&A_&_G&dJ z=gHq3_A2ft^8G1f!#E#cSDAdX$fc5mk6{0>2#3pZzRTfsyerqP=z45L_&RCm@w3?4 zPG9UQX#SnoQtS4GfsBY_m1-5m*Pw)X zOSZWsZ}~D``(RWDu?uf`aK(*mT_nOM-^I~U8n(BpV9u2Hry?Stdi0^?6~c+U(gFS+ z$o#a3UE5kPZOfv_2*Nj@=XLiv>J(s+s+`%LA%7+Gj zvrV#FST5qH(RA;g3ZmlfXezzG+JJ$NE{aGe9U?`Bh_a{Rfl1wbchlBGJIo2@U!+_$ zj(?*IZ!g;$Cr>0z;k^nCm28V`LLK#@?gUS*bi7gxuIXpdo*oadFsQz{(PS<73*abt(M1APA9Vt4>!RDzfPe@Z#^CxR^LnZl1nZ9{gfs-DXNy@1PS*+$-GP_N~RQaibf{afK-gEXTR`n$ncy3*xLg&f9s z&3u(Z&8or{px>~sLNz9E#nq-0J@6w)HUG`y)iXfpdK(QeAnZ0aSZBPsO zSvy;Lb1rXN?*{He>e5e%l@(TjAg#ZA8pW=?$`e&e`sXI+^Hhr72*Z!^XJ%%fw%)>Lv6{r6sM;@`$xp2@QvaTnBA(kz>2qD})5Ml0|rT)$Kg?v?8WB?u#+` z+=24~LaA*uYXV{PdPryp(Q@Y3oHOMd{;IY$ zU#*#J>+RXcxe;4lVaCB9P-$u3sZZuh@Y`E4lBIe~=Mo7G4XQ1~mpLiwIi%eH47;;3 zIxTI5M=?8x(+*)r6GSc%H)<`g*c{sWRfpO;3u^8RN3Q4DCL(!2 zSS=6toM=fcZ$0+WyDM{3F!L8mpjpbLkgrBe zoPsk)Px6lZgIINB>&q1jYTbjuv%kO2nL9(LH4j^8ox`@V2b)pO9Zxu91n&U+s`Z$- zbT4cG6{Dey$ghhUT0u-Gh0U{m<#MqEH&tVO+A}C8DHI5UKmP&|Bsg^ZeEwzs6z4v+ znh78$2Fx5FbVaHaz8?w?#a@zk(y6jroR&GdM(PA080BZ0*8dvhdnV4A_4LuLQ@O1 zfYBz~{N)O8Y&os+1;G&|d5K47GEi+9aR?)iti+r^#a&&NmFxqbAc_5%1A2w%b1rp5 zdH+#T?$e#6+qEmA9eW_Boo`=mMdiNg3ldXUXp_GPxLUj8kK)E};zBlD;{4$@cTB*V z8;tcYd!;PCD1Uuh(BD?1u|5-4Jl^IjSe&Hl6TFN|h@-p49a!hwBmMJV=mv?-lG6=C zx+y*PiiZs)>K6N22m9Yp{1$){32&FEw_o2r)9P3e_A?9PG+w@%3Dl`F-NC$y+vpDu zvjw{sPaCyOLZU=8)9(z*VQ)joz=Bu#PIN{Hu2~dMWe9Eak#~otQ~x zPVE$W2ERB?j62J1_WvZ>MxEJ0aF%~fivoq_ai-xzkwC8$^#f`FE zRkt$d;v85;i$Zslj55A^Eesu$ov$>OnL)IMZ+{kGmIhu_y`Q~M^uibM`So+xF3#Se=m@uoh4uhXm~66eBNu!#$CQBSn} zygaLw163PiNRa8TGOn9J&lSvyb~ug}xVh&$chJBhCMCP%?_FKb{_!mI4BRxGKnk}i zwVQp_yx8^niFx-bal}RWFmn+7@%0f%zfBLJ`H9!2BAur+hjWjm5vv#fCnvvJ2 zI)q(b=vkEDc#WB%TfTkzYfC|$g^)Vi{Z~iJ^JLHy@@4X>^bDV9m4S6)`M zQnggjrx;}(1WYVqbg>J?s5@XG&S&=cTe{w~ih?EFEG6o{FQS zw2k@JZ%HWE(?f4fvN;*s*7KG%d=pw#3+xU$I}nBJzN7K1j4!JSZ&(bCTE7kUTKY3> zXIXeO^C?GHJn`0k95YYuO8s!55>DZvQMu(@Y{`0eLD{`6egIrc461B`BXiM#gXIERtm0ZHa3iep<*>Yt=3>yOHuG()wk@J0fCOB10jiTTOz=! z7wJ0-z@lwWWzK8CYvAI#BHnTab7oshWR8!1nYO4L-)XH4#-$;voQJDRO6xs;%fnOF z-Eel5MKDktL5>?H8IIB2l74xVS-wI(w?24?;)_pINH-~8zbEG`TVhJ}U!wE>My|JS zkqK<(mG@-|ha-oc|8mOzORN9yX2Jgtmn#Stl#)$j>?SWBvXd{oQ0Qm^G~j9u@BR;Q ChsFE= literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(414.0, 896.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Disable_Size(414.0, 896.0).png new file mode 100644 index 0000000000000000000000000000000000000000..e2220be0a765d0ddd7c9d673fc2b2424bb282974 GIT binary patch literal 13839 zcmeHuXIGO=)GmmEpnwR7g`xr?y@_;?E-iF|bVa&=Ktd0Qh$t=6L7LPMN>DnXNt517 zgg_7}AwhaV2?kDlf52Ji!&&dACu`j+>rQ57&+OTIU)SuJ`DhH#VPv??Kt)BxsQXmY zl!}TvkBW-s&1G83FAS>eBa|;{e^VWGD#R!!j`DKRUtQPyGUXF?`Q-;Hs=HLWnrh}j zIa`Z?VRnI#^Sz_RBM;`GWDy%oKl=bry4Z~M2WE7GayeD>R_+G`*tAbJCMUs z=;%=xls8SUs0>)Rson^7(NIZf1yNCzUS_8H z&bCZVm3jZaLjS4YKP&jp5&nw>|Igvj&*+dgel@afZvgY|-s954idM+dOCMUQcn_nQ zkS*O*z@f|4&JX_u-v2G^*)P`M?p@3LGeT9WbVR{E33gC_5e-BCJ-Dffg^*BwvQtPr z@4hgxYwt z#x2vzOjP(|==F|#L#vAXJQ^7e+H=1fJPD#ACIs0Qab|oRfNLCArhb_1_bMGldgxI*Iwpvza z^@S{Blz0ITKK=j^qzm0;!>*S5B?;noB3@_0X@j5oFh*RGz ze)Ue5cCHSjNNiK3CG4FGo64#DHE77pQTXybNR~O1<>tAQDY+C?g(5jrR^$kZ2EbO6 zRzby&P=cbb`PZa#(rimUSoZ}Pl-9$1Xm}P^W=JbjQKEy$t_E#Y8z!=*!nSRIYFSVMx?NpKc&$tQ~5IUgArMEZn8dVL%?m+x3sP& zck+6mslF}w9BF2~4JelwGZ8XZr{=E#+_(vNn2h<5J0jM0-f$hVWq7ZuoLk}9@|z3R zMNK*Hh3~v$Wg>%g;NFmWbH#a`MEpP+OPjN=F-mmP)WUJ2+3<>g zS!@_5rE|?heugjKgN+;YcpdMms83A83eVgg6)m>0fy~e2r?C0CMS+Mv(@T|}sIC{< zYW?C}QM&$+yND(1@!QOiZx5A~AL?CV!m$~d!a`UTdQ&E8jnwrQo1qfx56in5R$KR< zcpi=4eZRs4`Ewpi^zos`$CHLRwrNkyOZqbfq~f(Spf)^Vb=pRcbB=vW@^?gL#sbsv z2dWzchkge)_J%C{)!F$|X4N95@RVR)``WOj(fn zS7w5PMV{PO%6z#b3dLVv>fzCMGgKg`Dtoupj=+#bmyiV`FdlFVm`^h7(YjFbf$zaYPCju`;8}qkBW*CoV;LU+yyaOxbkid@^;`i28l?N{WG^7) zffD{?ebx{1cc3{KGTg~6XH|A*yTS~Z*th=X)iGmN@quD7T8Q_v?BJDS%{gv=ys#Sd z@grJLaGb;W;)><8W9qEwE`Yk3aSGt8W}CI*4dAK0oej_JFPiW^lXEbwvp}MJvJ` zEZ4)0BkpUNPM#kk3GI(F5MCefr*`yQiBQ$pB}mN^o5bRnr%uYxh(Sy9mWT*QfnRGj zSp6(i2M*})N;(VhulGjV6_zUT0K0~JK^_Kz*q{_rc(A!_f?r`#$!Iai=3xO^oxbWw z9_$=xhP^boGdFmlnNBb(xt-ux5Kcq@bRis{;s)-G(Hm5q?Fl=Rx@uWFLcRDFC1K%2 zkB|QzKKS=s^Agnp{M&a*qpVkmcQ~R67z=}?yn)Nir8%B7zB2s9MgfMo$DFnN$Cu`lbr*8y*eoO z&`Ukigfum|&q6JF)zUOEXY@|U@#{$Mu-Eu}quMgL(nisp$!rd~XF+Tl23s9&!yyjh z3FX4LaUWP`q$g0zKr5q8C}6yLclHH;s5WLDb{I-nxCeba3We)$n}`qWe(&rOgj{zm z5P%C)lzX^lr%;3ptdXTNySdz%dK9()p-V7-n;VPtwp6;(wJ&m6xTeTB2^gC?=$F8) zOB$m@dh(_=^B8Z;l~NE$bLOc zHM>;u;(0o+H`%wtwMXB{$A^e(W1a!@JV-oe8O4OFO6BXuo+Ter2GtV?k}-A!5C3yW)$W@hUb}c&$I!#r#m_jCI<`$ z9pl0-4R9j*YW#0kc9ym(M^f8(v)+mjve=?M1sN0ObD&avJ8Jaao;$P&)C~c9WWQ*+ zc^BI zfyfZ>e0DdxbycNE96qUW8>NG4G(0SI{LHJAnSNF!Ql4Fk6XOOtQ)Z*Fzg7bB2aCvP zi@i$nZ^bQJd->!xc^<~kWZ#G;^xGVHIzI!|$X=4BTJEePu1%JyC>xHk_%@jd$rYJJ zN|Vw@-8X&lNg5Ci@6%~%3sQGySJJjOp6sbWh`Iu$a6?Dh=L#~V^-#86^G!Wmb?NxL zx6`uSPhr>R>dHo`xI{Z@{+1L{r@nJa+cO$(%+EsR+y#}n?8sa1S(r=PHfeml5DcEn ze0F25tqcBz|CGfngCriY2Hk7?Myjaa(xhX3yX&ZJwWW*SYkr$J+u z+ZoYTssfcp`538xEymh;WlF|!3X+jhg+`zdVt#k!&id2C^NiR1NvWyHocL29`JBl% zMhv{*L6g{i2gZ5(Sawb6&_PcSM&fzJxggjJ@JR{{ZMKcM^5eu&U=*w8Ye2^uyX*Ml zBx5r3Mrg4S_g;V2Cr;fM&K2&<3Fc|-J0+Z^gZw47g8Yl7lT$p!lCjlZ!1B9df#)*b zm5{@Dml5&LdD66!$`sHJz1N)fNrS@E_%dGjc{oK9KF+7Mhu;Pjquj$3D)*>^fR|rW(bt-8>A%vMbd2 zHS0$-iWmIi%YC`?R1Z<6fhgmXPX+1IJLu{9c=TghSNQQQCGRx0?MiNB3+a-2epuFG zyZ-d9UNG=1B3}TuaFN!e8fQ}zj;IX6~-Ez$9vab5VAhX|@)0nP6+;SE>A#2}B=hac)C&;btydIpIQeE+u z2sHgz(n-Jg-qgpG!h1fCW2qAFDP^);(A(gB~X?)$I>GvAWj z)YWZpKWc6GgHes&Z-5wN^LiJJ;yXI&M4+xH@TvZ^Xmt(r`lu)fpE0zk0Jk;sArukB znw*kZ>!d&*W#NW0bJfWVt9O<=<1G-GvvB=`8DU`hq7QWppW+Qcnn7!6W-EQRg0AaxYb8~Tvcd@@p`N6e5Wb- zVZ7ltRY}JeO{tS%M2{+@dC}K}XBR;J3Hs;XhD+vOtT1v$N@E;y=#du>KMU4K2)ZzL zMLp{+O2xCyDuCgD=fUsk$FEmwEIDk<_T6Y%&A-4wt=#Y$E-fdS(TNbx%j1~pCTImo z2+OCLCUhx0%e5}cdoFM<$6;gKYiJSalBbcUt8|uYgI8Qu=dvn}h2?3wRaj0ba*HysO7`_I+#WY}5Q4St=deSdAm$F>11 zC=+6{FaXF5RNOdW;{j=&lcZn>aew?aqS)IDP(I~BTchK*m6aI;$;TnB^;^UmW?~Ub7 zzPn1_FAK3-loZDH!vVq5f>0#s{8f4=7is=)`(5%E{(=~^8N+(;TPtu+_51f97LlmGlh4bQpxYS8M=%J~6J2f} zx|?$ureD7Ea1z7CDqDRzd1-OKrKZ}G&Va3pCO*kbFioh`w72d_TkIhG(*mhOqc>K; zHY7CImZuVrK59_@JlWk@96%_YW=ZLy7rc>SO3&DQ^=-FcS}>>IQOFEIJ?rp?K)^Qh zX6^HuYut`Gt$O%g?dr3=v)IUL_7F2`TNa?Rn(10(@#v9&1R$t#JXs5-n0pgueYfvFFm?s$ueRR zsR*z3_V`R$;!^rz$+!=f+@5L77tH!&cq`?+^^4k-SgUJnDbQUir*Y9nCgCrk4Z$5>Mky-q-T{S!4aD8=Exk7MG4{641Bsa%uR$>D?PS_ zcOBBxw23~|p3^_dcVGH2d}3dw*8ZwgI7kH6A*wb)>%>PV|R<-ylQnigKJ$hSBrGS zxVBXhtcG%gB8~^)b!K=&e5uS9X)*7`);1wA3P#Pi=AgPlFMk?Rn}$d#FSKx z(9}H5-Csj_^s3JYe}k1to8T?duOb-_M(CL>N`w(V1(U51`jk1ob>YDUN)7)f)LNvm zM;i(9ggAOz2y#4SS~Y*RFu~%vo&Qc4SgV$2O5Pb@vfA$7}? z!~q3CLL}wEPqMB=sO*}a=_q1HHgA0x%@eC`udT9mZhzMv4oJa#{Z#Sx#o=A0Od^wz z#8-t#K4K3i$vF?YmGfA(GT%1r<*$RWbN56{)5GHG{HZciAF1lGO9tR~bhXwQ3m(Ar z*o(}qf6R=OB8qY;VUzrOvYVl4s?Q5yKVKruAvxIjr^lH^qGD^ufeQ3>sa=WSDd1rd ztl820_p4swotbM~!!DGjW?4?ymA8!?54?>4!gQ;J4HAeYdtWPO6-y2(S_(Hz>+-{u zy%*~`si-<>!Hh!16w3VyX|AeKFn~)n&M-OPQB%FFk+5+DiEyJTzgx!l&GI$wVFf!s zVY%Vf&!P5vYioN;HF9Mg)id&EBWTHO@qysTE0AAfYJ=V zr1VEGu9ZgEi1~0gaiTgx`m>aSdPgf^w~2=?6Gy3=P|T^IH2jTA7wL~W)=_NCLHDmQ zyy2usyq&P`EOH@aIF4@7g9poor&tX*7;@CaO_M1~sTlo^(_?x!g^nAe+^%~m?d)B9 zE8N`{N~h?ydP^8j(@#rID_&n`R7}`UeV1ZP0m#sP_15B?yU45J6k|VS^qY~>$}S0 z@cfuC)nD3aH3KZe2Rb@m^`?Gyzff<3oysdf zia)qNHjP_ z+;hmlU#F+H(ZoF-Ohv@`rfhlZ!&B!gE-B)w4)BPGZ}(R`iM^6-U|-|yGt{ZT?F%90 z_s9oGf1jk!A$Fui_81Xa_M6g_QP3Z16Y7_g^7iTjZ9%b^uvwwt#jKqVOOOFt@IwW;SES7#Y8jdU~B=lp5lIBS~Gf4TQA8Ix|0uztglM+nByd zL0lVeDTxQe9vYax_z5otqBfmM$B7rm#=Jj@>0=^a8pkEVIYJ)jBOf~R9o4YqIW4Ui z+!%gfWfZSNu#T^X8}`ODzJ6g@yA}SjeVuLzj#Z#1X%L78fJk!JQ@iVR|IDP z986^K5bsukx$?cA(R0Wb+jmF%K?_D79@=@`iZ~#@4+!l$)>NN!ig- zSlyHAI+*wfxuiB*+s;Js7XXLG%5Ky-h^O-re{hjzwy%>%kV!hq6m87^D;y94E=E@| zM5xEbIVrT1>R_i6y$aR!CdCrM;zvtTZ&c`vnz(dCzW^wQG(8!UIR)QcHIdsMe_PmO znXUs0hBSIG{C1juY@qy(E_iRDB1T%J*to+8 z7hN`SD8^yB0g}M0g6ZANz`s90!OQC;VAyN-xj|G^>{b8H0zl>3VxfMo|9-k)o$fqq zDR&#SlNa{v=&yUm+qO9Y^XU=6iFzzz=8}N;Xh`-cb7^DqaK`wZ8%(Oault=iet;?b zkXwF1R`++OM)b38|IG@1((Ek57H;qcn!$p^N*a>cLb@BqgdrA6b?N+rm8_eI>4QAj5IN=FeIms)Hn6B{&*UN1N9cDT_}Ay61W8sH&p z_BABj_E1f3eEnpn4T#bI7|5 zf;(dip%=Tp@mmpJ4-;0!+E8LP$$w$;H_ACs?53nbpHe{a6QB0baDeoggbc+1?Lj(| zeE|#*r_h3Ok&&Fig~_=htr^^+>5*!O%~JCwE*_oJPzcEIwko~guyn=EN#|+c-#vD& zSKF!6f!q@u=UA4-{S2S}pvvW8#>R@W@A<@2CS9%D$WQ^cM0|d@Uk6h?h$*b9^5GX@ zfDK}X=+CeF@uex$3k{30Uyy#C&0()t?t|G&RS93*e6QjnLbm@J6hBqyNw@XZ1KPv} zkXz+>WPP%!<3Z>Mof7|}0@S0d$S2d{)cq7sy&F&5BEykI=2gq>EWbQ+?a6aI>$rjn zrn2w3kkFiDOSjdDXHPypHf*VNm^-p|bggkJ3-Xes1dS9L@)q@$)KJlthzy7_rRq z_BEC>$RM(*a?_d&WOEco+kTdS&L5BXOU%{|3y+X0{mpu8cKpLf)>RXW_n+Oc&IwA-_Tx8f zjZsBD`KT`p5$j{&W`@-&VSd>v2L@^XQq+9z5#vSTN;YuuoEfFx2OP|-*oliyfVP!0 z5a=%=)jQa(2yxp*s&|cePZ{+GmB2h~rXO+BC%jybV{B)Aux9FN%lsz}c4r(%ecn?8 zug--))-BIrr5h&ClEZ^D)L`0>st&+%VJe;7CSqxt>} zjw8;QE7e?F$A5DgX0wKrsHkZrkj(oe4+mSW# z%Y>2V&k`GZ1Mw@MxwqbnA#=<3CggwY=;4MagrF@-`?_jKiNR@1g6w3S@LeL$gE`rtmWp_$+k&Kvl8&6Md)ZVI!JyqG8jEscpGlZ2_ zh$QjJ1O|Ab3eqEY?2eH2LPN_Jr=%@MQOI80iZ;|6t=Ls%H0*>vWDK*l+(5}-5x0mc z+>QD>?Lpn&u_L*QG`wG&My)=n00`EwK!d$z*N70yxm*6-G(1azUyR#)`@UoI75!I} zh9u8H)^QiM31zs^wiAbEzh~|EX?Psk9%W9|sQ!a7AFx7fGbcyaw0h7d4zWgk*ZkzX zfkTS7LqopWCEL$06${X6d2~+()9`dO!S1azU=bq=qB6+`DA7TZ+=)a`ayS2-^tG@K z3!=qmX*bQI- zoMru!u;Jek6!gpS{B>T~$(u7`QyLRO)o1H_Uji47qwU0BuMZSHIypbQC?4gyxYqsc zAd%1gIe0E}yQNaB&C760qj8frds6)SzYQNco+=X^zEjW+WzS~AGb%tn)D zY>j&bKH2c0rB!T}Qub50Hn@YJBt{k!oYD9=C!t#)r9Z|Wx@V^7&wcKRnT1v5z^_d#M6uFQ2)0Ga`I|cBH~!Cj>_i zTgp<*WlGR0P(9G9Dry1x5BVupV}q96Y&$Xi--Fc(UO!qn1@zr)^ct`1NZc-HNl%~U zBLeRz5ZgFNOxsKCFe{_S7YUK|Th?|g6W?HH^xBM3K&nTmXUL0~v6(AG;2$VGhDq_VqAC!i^_Op>_|=#BV#>+?U~(>BwfMCN;>f}G zGv+l(kNrd%+Ok@sFNxl|Leu`-=b)5m!q1vZI-6}j{zskRuG*Nb^}rHBcU+=}lZD*M zW)yQzW5V7gBxL+)#c<&73VYc>mHhQv~@@rJZSC$uBf z&Z|GJ)5nq{x6WXR^eauHlz|ym+WIt~RC|TkaA3r}-zFb5x`Vq+Omr_@Ky+8#%I8Qg zW{&4MA63ZfKPx1{>|)pwi6hrS!$L_`)4wLBwt1^Gwq+^fnW8(Q8>3aiIJ~-w+m_{; zF6RtTv#a@H!)#aYz%j;y-1H7N33-oBPycr5c&?PNwHYe~(`P=DOl)XSYazYJP1VRH z?*w8wTvRaW>C3!IIk{Z+VCLHcBcj8~R+(Y(6fT`8dV#cFYl*|<($%jx*5001_h3A9 zJIgT@%?HA3fhcHG9vVKh?i_aZ{Ynd%9K7ilau%AuD6_jaNNV11S~NWme$WS%`mESe zD^~4a#t*6N*)cqC&OyG)+lCxk=tBC+&wke=&Iu){Qi(*T11BZ`*vZPx7|2k9fh#XW#L~h99%~eeV-g#iS~x`Zff4y5xnh z}~W7gM7Jq z{K|cAlTI8a0J1e-;)RCB(|wSDQ;3$}77|tB+N|}$m)sq(nDOoJup`BbDYV6%jDkb~ z6hwt5=W78YO?CxK72vpXI+b&x6I$v5ukJ*U`V#U0P8nIL1(AlQx-2`z7cov2|1%5p z4mIFf?1u9Fqoh11+l#kqm&H1ELC)J>Ki`bbd-)?+Tyefl;XLq4?REf~hp>SUU3ZNS zK-k_k1#7J{)xYSKw*0K}TTcYYT3!{w0hUbX(at^PBhl;C|WJ8e=X)P)Wj|Eyvc^zaGXUNjviMJwf=_ z9K3>eoms`#bwTOTBd&+j3Cu3rezvC9XZWv#(9TwDQWA|nlitSP3QGnZG;ht7Q`=e`z*zXd*64<_Zu<(v~>Fka#j*+C#(|?Q#-nHoOIw zG#?-RSjXSnt6C*UtudAinffB@wh{bH(Y$z@^KhPrXRdP_11x4%wom!i)%Elr&%#V2 z%rc1N2y2-A%**D5u2+vOx>rcUt||vv1DFr54nYQOg2@Nd@F$rwub~{tp}#^z#7fL_ zu^f>UO6a!L<-z5RGxd}>jq{~gY@@ezRR?7A0efl&+{QaNZCXj|{hw)y3n#3nv%8W$Oi5boR+NN z&pvSZMMW!3O9ex!an^p|_ySfBH(#PQbK^l>+en6Q_}JbJ_C7+Ev^8N)I(ugG@Zkg4 zARF*R0xhj$!oPM?Ql*{&dUJx^*~G4%udLy#@QQj+ckt=H7-Z)ytyg72Sye>CLRj?L zEwK0EpDBB*qQmKrxq1>wH}~RMc=ML)hl-T(iVuv-t!Cp&*SaH1nUp(DKOD+}%StMA zN-l+uREW%ej-H{6alIb$)^Dh~Sc%tGxsI|Z`j%TcPG9Ts>;ki zm2tTLttoE_qN&K#uwkqZONOWZOTCmKZSQ1^4Q0x6B$-Ld$Jly5?_ijg+I=yfee{O; z@TJBRC=agXg1c)EJUH67>R}w7l9#qtbW69jWg-risPk*LhPYXYfd{I-=EU|3b|mi$ zOMcxH1y()J*q#R#Z+WS3T@6_Um(&&Wl`~qf*jb@+eGSTV#N_!;YHhKu4cXVm7i^Ht)Wy-$w{sT0BLXu*JY5Ce+c^A1-Gn)U7 zmj7>@uJU!FSy3w5p2f*gB=O_9dj#KBcPY6jilE#}cYU#U2r+El(G;)Y2fS zDr!xwK@e(6V@X=EM}!KgEg?Z9B6)Am@qT%6aX`<=h*^!U1? z?V&_@I}> z(9p=+EV;NJ9%qvG{5gx^zwcC`*}tAR$jk04QBtOlbvxAhZ5XssThUiW`mY!cJ~O(^ z6UJBTC}LhAvFt0)U-8?x2prMC!ouPN2=w|#*8^67Og@qyf$M2N98lul-*3O~!*?V2 z?hM}}!S{&xJstcHNQ*!8F}%_w@9g}jYo-0>s#DkpMMk?n-fT_Ce+Mu>7aY*_S2 zwfoO%Z?A&O8(EK3OgkE=Ech)J{A-*K&J52)kqgmd1M3(PEQ*%8ID zx95=GEITslp+!2zN{*U>QcVWMTqghLA3&hYyBO9zG_<@{)1le$DL>|f!OPy4Oh|6| zx|+z?F#oFR6-1n_{GMB3dq9bH4JUocl*{0A?+5$$3%VAGSo*0G1Go*vE?Dfr}fG^pf8hq5I`>qXC(HZ(DR#fb0y;d6G#C zXBC(+&_|5LPH=HRCTu;m0 zx;lcz%Fb|5g&L(cc_x3@BG*1=L;u?tC?F36GJ6zXL$bh6c%Ua|Z|f9|yBJ~)85fzC z257+C(gU0au9yrx3lQq|{1It`u#u~U$pvo;HlG!2z_}GHn04)G68}T>WEiL8Phjs* z+5W#qD!s~Da~!o*3mRZDY1wdYsM-AerQ33RUaFU{24PVfwDx6I-i+}hDAD;7L9T>g zP=~0`;2yroV{~fRRu;jlHQ2+?SZ^j?fQzFvoT`+`M;o+Fo4v6TrM(wm(tjGHvR!06 zs$3Hi?d9Z+`1nuVj(VA2@QJ$f3~l4?1<~SWiFjf(hb=E(A`rw@R+yk;M>E88UiQ4N zbuB!mJ{{Lby)~wD45WPt1j@hY{lzRF@~zUUZ1kI|1Lt^UugU}0SZNa#>TQ`(v1k)M zUU;r4LhjGcFK6llP8ZO`4fp*(8sxwX2(jvSyR;{XJ7o^=a$3x@l=Om1Y+<>WVoLoa zZG*4vBXyUW5LnK69=usLfvtdN7(XqN&4ug%P5%<^IB;dEA?8=op!K$g-<0{SEKB*G z>T^b}8On<({EY6I$y<=)K7YFMbhy;K`y>+4-#dI~+_nunCvmJYu^eTim$2^yWxuwt%_XUF^Y{iCC@ zQsy8ynV%y{S4oXs(;gX^U`h8e$HsH>tlRYpoW)3){BrTo9<#k|zF&M2-rk?z=Hi~w z<;!`Lv~F6V8qTMR62~F zbQmN=jLd&jgXY*;IEIcN>xlmu1S)+e&{g_mxHKOP%hv{5dykRgf~EqL{viDUVdHgot1ZZnRxRIN+dGI`~Lkg~rjjl5{*( zkzhO^M9TEb$$-N4Z2%E;z_06a?g zxmGgSL_9^bEorZbYL9OwSwJ~XC(g*mdi%P1e|9ObUrbXSji?5JXJ~@axhKXOv2(@p zYE2EMk?uKDM^tBFYQYp`_DsLj&zgS=vHKHxo0-L~Tp-uo+?YQ}Jq&U@ziU>6>Nq1) z{Uz!Ffzex-L=_Z&4OK)wYo*>?4AW4)s}mdK92pu|ew9{u?AD-JU6Mo?;t)I0Qdk+R zObI&^

rYHg9?n9fI^t5IyBb&!f)}LW9Uv9^FybZV!WB`XA~Fvg%}?_k8~rLP8I4 z#!skTHv=~BL{n3g2G?T(QdRl`m0&Ap%ZJcYcmx&l0ZcjX$^2raJGidu{C4u0ST-z= zkChI+U`-=r0#1OUmqR}-K?oK*T?f#`tSfY4aBUlM5!%ddJmiP!^T{?j2pI5{-H)K1 zuXO+=?#DUK+?-<;^HLwwH{v5k6Axhph{@0x_6@2p33Ppytz5Aq=Tf*yYJ}$ z?;6~T9Fv1=bi*N5h`i&9vIBQn&2HFrC8+JS4l24KAk3qyzMU&gs5r0R9QbSGu$|l8 zL4#D46xF->y<%VsEk7{(@YxWbMB_lpAFM04hMm%v6 zd^V=hwc%jt!i22Slv~~uT)%fU$rm?{ZY?^a)>LE-Zz|VUq5YVcGO@&~b*dU@Zx0H! ztOt2FwOit?3F@X*+}qy@K^i{+0vAgsz%MB;u1a3>*JD7g8-tzoU^HCL)U-rUJ@RbR z0o5pJW8Uqr&|7h`MLDE3fM}!{U2fQM4T#FLA!(q>we3Jn zG$T?ZK8?4kFf|U|9z;VCA}-uftN9KA@UZlFJRslQ#e#=Xf}3nxxRtTClzMACFWMRv zt>-;K`g}?l?RKHf;5?o$8>TLhkW+5-<#?Zpua z5`KEF5vd$U142ldx5e7k?ty6Iw}StGV(1|{{JEaIc|=T4ed~QQDepswkOo`nf75aV z`AM-Oes_|e@$eJ9(1Mbmt`CFrvz0eXByVaAzeV4xAU9%b{H@U1h6?Dq)dj z_Sxm>?&gG$XzR_dJ+<|nvaN}h2m5uwQZr*Z}%N>%tqBnC_*`VuU~U zqJk#5Q#paTlfQDn>R@7K)ZLIb2RkZ}>^GgBw+t}^m3Nkl!C5b~CHRcqD;bv*51w(V z3{`SClOi!JvW3*^WPN#ntJWxTl~+)zx_ZMZZfiEvIASg7naQEz`J|89(!aE4sJI5h zKIJN*yd!!K=rsr*dV1@5@;Uy>E?Xcw(r?l;2DDIt+9^J@zz46j682N$I7A9l<65><{ z!njrX*~Kt}89#xIMMD#tfjS23XW^X5Ej5N*vEO{Fw===HS)&cHqd0fKwM6Zu}k{=fTCJ%j$lu&{;rLcm|q&t z<+!N8QmIaQ6%(3}3iR++|-)E(EWos5%8`>I_=Ok zijH}bY$j$K-2I2l|Fr3_+-A|=$tkcFP#)!mE5`HFqIG$d0Sc0Cvny_ zV^IZR_#gg!81m1=M^R6z7Jry_d2jXr08J5ZwUEv^vSkCGn)V8)!DX!2v&KRJ=SwSW zG;A}9%H6W5$hd0jbw(u;!4u`+&PUUq?$I^+Z>{8i`l-Ro7cRBhk>gpS^>kuPdmC}|TZ~s8KhPJqo z$4IH(51tuJzB+}v>9BcFn>%im4?lT)V09>6*(@kT9y9pG+7u?m)eIiK(2)sLEIu~J z{Lei#$Srgk#7@+gngHG$cE-HOLfDWsOzvet>9#yFN`2hCukQinT{VS6qz4Et_r#LN zRY7v#W}uRDyy9R}rwf#<4@~=`inUbA?2vbX;x(KO=rBi$fQOEc11za_>?K; zOJVQhvq#D`lx-GytD`vs9H?J@1BjD_i4So?m$jQCFY#r;8qphR0o;y2QSuq3ZR+kUT&Dz{u=AL`f}J{-QS6`| z6nxyy%VBUFYG(^|ZHJu8C}_)y;8ar^ts)n$44!wIO`z>v;jcS_4gijNZb{^vMPNqF z&ydj>>k6*n%<2i>_|Xq>4B?9V7o(JDJ=y%LbT`hwIwklt7>`1BT~zS5nTlGF zJW}1$(fThCsK1;j=fP7X=->W!r~eaOkAbVZEf0`BApS*Qg-8z;g<%lvYz%F&o5`+PAnQYh2i0DK0Yzl;d@zPDuS!Il zG}r=lSSOCJFbZi02&h~C0!EK)-Z}xOI9`ICxVL0srFs@I{0b%PIH2m}n;I#-@Cx7_ zwBK4a>>Bf74K%uEp=O1R7+GgsG7WKX9{6-oVXseTzG`VO$gFt1_>4m7?{>TzuE|@( z>JS9C-s?~xtXPWLO`XT`0nK%*IwEXtklQJ}zmrzjK*=qp3={d~4h09Seo1uxd)I;+ zOU}2MZ@tDV#1rp(Gm(EXu2{&Gpc`hg06d0tViq)tX3S3>?O3=BY*`3k;FKY4yT;`z z%GvaOYP%FhI2_>K71rK{jH5rR4m{DT1|{m4Rxe>^U&!Pq0PjLGczIQoJoZq!!;jdM zk~~`>0!{_htGbNv$Ho zU|vU3%kSy*_krgi5jWku8w(9{^pp)PlhSA6$8pTm zbT-dW#Czf~GGDk^q%GZL41ho%yD;w6{Lfo8pKjo-de;BR#yM^EaQg?wr9eF*>|sN& zfK1`DJtOQFX%qXPXuyHQTd^z60sh?lR>+0lfv21&UWnFDPVfxv+Utr<1e}66JIdpU zroeLmz^ihn8@$Br^gh{_DJ6KfK;X@O=xpP$-XlIHfTUW(Ig9uD{!vCr*DPuD$1p7l zT=~<0{sg8NSbMAJtZ@3l(+mwoy#*=`f`sYS^~?L*1k6-xBTUP2lSC}mQdq_@>ZGuS z!}AYJRr1B!WH`zqf;~RZ0ImI+A{ zK?ZOOq3G3TUf8h%?>m6aw#!&4qfY-sI%Xj%=hdk7QwB)-%+Ie(y+2#);P-Ys1~9bW zIoI(QMLELB%?m3Uw$#bJ_HsNJb;^8qoD59wx1^CN)ex?>X`?quRS9NXw4~9D>Q;TYj^5@!-4uk4|F!)QQRl@>?_2S%iTsd#JN}C{%nm?82Rk(2g8VKaGxeqvW zP!aya)E4(V&_?sQOlx?L9Z3#U=L9^vc;PNBk371?(=F`U9^Db z>eZc{c#xei+CCBo-R6hZa}QWh=qS4etXHygJXp$&uhfzkIW4<&?FKdvCpyC5lB|y^ z0PH{9!~L~_&WNA;AxByX7!DFXmhk4%9v@YeHlZQN={BRxQBYVX9aeHc*$ieB>h@kQ zPq7JKQtyL_G4{&I{6fW0dyk#fd%0LAdSjQFTvetkM>vm7hl*`U|G3-vo}`$LB(pe7a~-MADP7Poz|b?5hO9;ABAvPXT7 zPc`6s;eFY&`qJi@AhhJ`xFurk#46u<$%^(TANh`BeKp`5*^fz*6@GD9?@ zTZR+Q_<+E|Yj7G2$X#}S02Ka(KL80qzx@0Ec>50m1Uh!X)(OpFwrE=lq67RQj9_}9 zTtUZW#2R$t_fK-bBkd$t@ zvitk<0KWzJf~L|nUoce0Yy+@{*mWV-`lSbH;{kYUYs!w)zWMB9^UM>6=7nAa?Yb!m z@&?V7h4!~orZmV*F+q*;MDr|PB%wvVCRuQs31`*Bop0_fVa3dEL9oqv2gOGAQDsYZ z&FXb z)(@Wq&tHg!Ch(;RdR)+M?93szE+S3?A~% zOSc}3+gf5yF`nlngnPAb@dggG=>9nE@*4;=KcsyL!0H3j=Sd!uKMx(~TPW|~GB zt1shO6#`Zlv>7=mNYF$JM{-IE8<*8aE#vO;nC%8@cRz7G+brSjTL2?25z#hTqoj5) zA=Er(@9Y`}BmOXYlf}gnP~P49!^-ObX7;4$tCs>hDFk^KEyl3Dvk7g>m?ABy-QabP z%u#DDPyC?*8@Yu*TEy}2P-3)qcifrnHh94cPG=3&9monxZ-LEqr-5HeQC*wjEun8; zmGxSneS3S+eM-;1FpJaMFMsYhxy@P;s7d(AS@yi5QLng3ZtTcM*xV8mtqaVF$Y32I zFmp!1f?D4Mr)cH{PD~(DH9j65rTVS+^Xe6f=VV6R&HA|oHh$pie6mt1aq-#F*k60v z<$W%T>dYSWfVJ#YC))L+t!mZjn-P9+nA0&+W540S5zp|=C!X;muX=VQl+N<_t->0A zD+qGD9qjARJ>Ne28qyl&X5Ch-NsOz=)Q|!=IDFuKJKQ7M!Md(kQ=s;)Z=@PyH1ceI zd8E@bHUM7j!N@DYygI8MXExB&COf^n{#LIC&CrKTy`)E*1R!ifHaFd1A$%W7u=1zK z3S@X^Mv_wMfzv?IkgL8;jN^aaM5RF57s~l1J8h<(H49;&^>u~$q$*_il!jo>J?7{u zwCHPraM3ESG`=bt{dLnbathNbGSBg+l^4^LFQ8Bs>3p3x`oqn-&dee4D=s)Uj}+w; z#o=PaiTc_h1BZk&!TP;@xcf6N@%-_#M+i2k6(No3&K_nrve8qKt6cFT08y$6vQ`{~ znU>k}n+48Znzu4EV!QUP2!(wD|I4)X&@&vz$Yf{p`SaoFMtrNPmj6RqSl-LqWzQSrA9}r|9iW=CyK2o znbQ#ACpSLFY0`RwC4DZ-K*e%lq)Z%2pj`sq ztf`*pLVGhUawE_7kQP@T!m3YL&N8iTWmS3E+71x8-Wh6bkpt(oq`p%~=2x(wXo)|( z;@tDkwT5eJ+@`uxEy()gNMRl^=OluM(-3g*)I3tt8Hn(a>w=0!R2T?28$4$lmp-;p zTGJl&L|uDyWH49OOJ4gu(oze1D!~VcMDyF$xX!{Fd540!IQN8ai+n`)n+KV+G^}J& zw4%mBrZ1bt_itNE8TmC$=A&NWn>EaMZgu`>__7_0|BC`}Mj#LGTM<}AZOw`%Fkmk4 zP;B?3C1+Q2GxHrz7oGnK4#}lVNi@jWj1}PyT5?0ORSJDMfmz&#A3Yz+qZ@Q-npLEk zIZI=rLq-~(p*W0aeX?b{`C4zU!xE8);ad!b$G{3(!~+a_por^~N)E5Pu{UrY1=O9@ z`Foxb%g4uau`X#fKfcpbFT?o>sZ;HXt?QY=+W+iEyVivdvayT2b`5ezn5J4n;Gfn8 zp|L}|2NtHGP%I>2zS=kKf7|b&)Fbfq73Jes16E%%lR9f2FK*f02eL^5dk1O;7Q)|E zk*Or!t4!-Q!Nk)MHglP0V-c4e+aqs5z^jd%Pf(juW3(CFLg_mk0Qr0X9owXaF= z*60uky$|~$ySrsy53qkv#GmT)$xJ4%DqccV&@?|Tqqa5Z6}yw_cFnBOe{!=x<-w8J z>6o|_ZR{cVam9Z@6Iz2lu%wfXNn-920Y>}!cxeIeHJ+yiAz7i(aF2x9%{h5!^t%iK zqtO5tNA@+(&OTVY_ybmUwS-k8qwoR5CS#A0BaG zM>Ds?cG^;>rmadt{x&==0dv?Z>6`LN09X@|y|Ye3KnjcxoIF-C>RoS568Tci@A9M% zlP55(EB>P_p_3JRl`Yv|atEd;kKHV964Jba#wJ`dDYLpMO5E*jEt|0|ojs(;Km(kH zvf`n6rg!VcJF+we68R-bj9==mE}5($p2AIlw-xNSSwN*QUtKeIWfv%C0*?5;B=)pa z^ri|xH_{nu8{Y$~B6H0FsH|UX6=xTO#Pq}T;Lc0kwCwtF)0pPI-h2Jeiq8F z9O-Ob`+GLU7qN0BVXh3RfR1NMS^#?eTUCI}&oZEMrP!#s%PCUvN1`hBz0PNs&FBrs znB!v;hR4|bmz)kJM6e|hf>ehKcyw=fQ2LyCPNhOPh1?Mz>c(55MQ*8~o}&etv%dxCb<2Ux0cd0%!8w{U zpWomM<1#n&q1Z5I&Wqg~m#~e(;ogg3Vna~W$r@+MxK8#pq%I5}n76t{k*y>8v$nkA zDVWZtj0e;!F(0b!qGaP3w79|eZzYo|R>lB-?#7-;Bj+5`B&Vo)z67kCx7u@PxG0uh zysdz4ZARQf?KJn?+BAG+-*dKM;rP3&+a1e0XF}B8UF~TaB?cyMZ$k}2?|E1-=H;(Z zoa7VDH+gPtzoNIX3Z-=f$Zp=x<8H@e(I3s5MdF5_ca~eeP3W-G(cpS3oVa`ENW0*b zVuA6@^41H>1H;4p%Ozu%9X1xenVZ>xKQp3#Ll<;4uB?2*`1=aX*Voe>ZsNvPU@aKs zZY_RhYDL5E!YcMMRUZ}Vp21SKROXA*tNJ|j88{v4vVF*Kr*ep3)0O=|L9dssnioUZ z;Ck0tR$zKYr0Jye*X0Ivc@c`EPLmC=Xt=|%(SorDP07yWD`(2A>W^G!|8)LO)X%Yh&w7r2asPb;Qz49S7Go+Zn%^CZ8@uje5SQTog z=;MYnax=5Be*t;pY^ovBV)e-32*H1|*Ye6Y@*$`3m%BhogZ!o>dD-GK>KiKHm}0?^ zZe!uZ#?z5hmdfZ#A5qeF*6Nb$f>L>}8lz^p3BQw85zTI3J9u8L^PBv4EL7V6+g-Hu zA3M<-(W?*#(ZjSMR-qA0BP+iEtJ~M{@V+K_N+~S_${bq@In+Zcl{uXWWLf^}kl`m{ zvWItunL&mt=OU}K2Ns3y+^RY20p9Q~qfQz<*C+jj@NSZ9d3a*! zCeP8RFC*ghkMe1viHZ9PN09H@LTxM$pn@k4TcsKo1%>2_ba!efOtmW^HixY=W6+27 z?Cw^pkl);=W2c&70WYH1Q#CQS$8|x8WB^CU-hcwQk{NSZA_~#(TdGE1tsbjCqcd^X z@q=4^18YICvX4so`bwBWAQ|YlEVkM~XDbcqwQv3J?GZh)EG6YpVI;$-kA>}xTEQb} zRWINcUr+fH>7`g7xQf8#>zYTrcPs_dh2C|4a0>!m;>%8|j^CC~SCec_9tuvoA5q)r ze_F~;?%~%c!UsI`#&M+u<9SC@8U?LDvF9C~fV5uyJEVE#XdYsqcM4d(M19X!Xu|uz z!C5W7M7}!>B`SZ6hob&@Ns=BX1#BBcH71DvG_6)vrrWB8I!IU7vz<|_!jXvd1Fv*$ z7L3(uOp~cU|C$)vcsD56d!zCxzZ~Wje~~F|?K7R9$1^fDgxxzH`~d(P1+i6-gYndP zFX5z$FKh|l+_38Go4n`VP1Vc)wiZ{ETh!GT@u@9-9Cka zVdEL=B(iuRIwzp7N%=px$eYrKc+-<>E=ErsF?&V8Bd5KgYNzD(UzEv|)I zJ+;`nWZB2tB?5+mpAqnQ3+~qZ*uZ=RCZ#q(`1q{D9<10?24prMUNzLqU-g@2O4<5z zxb_(K%N{0x{&<#L>4%3@D}L;#YUHG`Vx^^+4VO-@P4cSb(=6t3r1;T?a~9O&(GSAy zeZ6*JmjYTmJ&WY?(c5HjtuIeUhAxPlcasWW;p{G7;%{DpAwI2Td22}o>)J{Q-o>jx zmj#a3>8&E`Diq?YbHk{eu_3a}o0E@C{YL}*K0K7J8C}c$rY#Q_ql_FmP2NN>#i2?2u4}?u} zvTZoimsl4&8GQC`Ih8394Kn>rVEhgz={83!FgSZ&k2K!PM5(~n1gAS z(nhlf=GsScG_1m2>*?>XTK0MTtp`6zc}iXERZ0ovsGP9ljM1Lc)5f?`TMD;V8NcG- zAy@Z%56SI~5s_}fqLgp+K*F$IZ7mj07h#RaWBI3LK&47DVWQkVfXdNlY+R9uju4!3 z->cbQ3UTn>?i%$UzO1NaH&SZaXLx^KpBr~Pf48J4-H7AH@1!=fmXF$^+KWmd==B*q zi(?z@$n(l1e$5|lUOQ@9++K4Y;Fbu$%uuO_C+-r^)6mnWl#d;nAH~C|Pc-GF#8$I{ zAhW+-QL~K#qSK@rQ!UR}A0iw@q^I1KDylMsWRV{2gl+&>v*iok;d7l8`|J&JQ};}t z-UG^5WJ*?m??q*NEPr8sm+*dxNA1# zc7qW&zAM!nw!yNGTnr8l-46IA( zsV?_|A_CYezi=pE{qs-)&yG=hkd+_dr1VG!lg!5;EDg+VkQv)248Rr}tkp+LL_P%9 zW{3xu>L73Ad~vBY*vXtiUvI%CYmU3Sw|?$2>&1+e4~_l^norRIdTQ?PonHTvQ$Csb zpwIrUVMgEJc^J2WRADMz+c_H>(UY`%mwOcz;&4+xWFR(}`e{zt&0%b=)N&Z$Y5p{= zR9#pSSY@XWxP3q^RaREsD>77s(aQasd6UD2C+Gm;`y@~ue^rH~(-@|?b5Aj~lKw`| z_WN@WwUrb~GTL2AjgLII8~-_!qBCL**2p zh0v$kYdABwXXUg}_&2So?t^Ola*9v+;0w%pi3O_cs)G$J7iicEw8`Xi0Nx=q7}4HF zJf)y&c06mzLmsgJbY#76wA9J#mJEn)WX-q?-s3@jcLQAjbp+Xh^1TngHL)sl^pGn^ zwc2OtjCO>Q!YM_|g={|EGF1Dxvj|0V#4 zeHR;BTU*6AfPL-||KIeWZ%OHRSs7sSKz~R^9(W#;0RfFXpzoLOd+^;3zPrKqQ1Cqx e{(l?^WghAe8k`E(P66H#WOv!|-_;j={{CO3C%pRr literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(1668.0, 2224.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(1668.0, 2224.0).png new file mode 100644 index 0000000000000000000000000000000000000000..451874bee94ce8aa58c357da7b8b50f6fb8d175c GIT binary patch literal 32348 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 N_>%dbxQnh2{|^Lyrab@v literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(1668.0, 2388.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(1668.0, 2388.0).png new file mode 100644 index 0000000000000000000000000000000000000000..84e20ceec3128cc6a138712e347e769d38713475 GIT binary patch literal 33768 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 B1$Y1e literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(1920.0, 1080.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(1920.0, 1080.0).png new file mode 100644 index 0000000000000000000000000000000000000000..fdd1f661b76a5bf3152aaf3d10fdf9ae84ff2606 GIT binary patch literal 23336 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(2048.0, 2732.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(2048.0, 2732.0).png new file mode 100644 index 0000000000000000000000000000000000000000..dc4a152d5f3bef24fffef7408e91d22ccb987e71 GIT binary patch literal 41199 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(375.0, 667.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(375.0, 667.0).png new file mode 100644 index 0000000000000000000000000000000000000000..368b48b7a196bfaac61818a4559b8db24c9028ee GIT binary patch literal 12641 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(375.0, 812.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(375.0, 812.0).png new file mode 100644 index 0000000000000000000000000000000000000000..9823c1d942ada5e054b498a790b947a04596ebfa GIT binary patch literal 13223 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 U&Q%4f6g1!$&-5uM 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 Zyw2O2Jf&<$KA%Pbey;tjT*dbN{{Rc+*#rOp literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(414.0, 896.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(414.0, 896.0).png new file mode 100644 index 0000000000000000000000000000000000000000..8cac4691a379a01895cce28e98ceabd577e0dc92 GIT binary patch literal 14782 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(667.0, 375.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(667.0, 375.0).png new file mode 100644 index 0000000000000000000000000000000000000000..e2f788c57ba26e329efc2e4db0cd8db4d918979b GIT binary patch literal 14097 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Size(768.0, 1024.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Size(768.0, 1024.0).png new file mode 100644 index 0000000000000000000000000000000000000000..8d728bc837ba4587decc9906a741cf703ab42b45 GIT binary patch literal 17958 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 ZtmR*+^?J3=19bqfvatOL|D)$`{{vM{r#}Dy literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Transform_Disable_Size(414.0, 736.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Transform_Disable_Size(414.0, 736.0).png new file mode 100644 index 0000000000000000000000000000000000000000..63881801fbe90157061bd9b47f5d456918f4780d GIT binary patch literal 13113 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/goldens/ScreenLayout_Transform_Size(414.0, 736.0).png b/packages/patapata_core/test/goldens/ScreenLayout_Transform_Size(414.0, 736.0).png new file mode 100644 index 0000000000000000000000000000000000000000..2aa4221736cffda520b290e87e4d92297db708f3 GIT binary patch literal 13984 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 IYW5NT2b4#}p#T5? literal 0 HcmV?d00001 diff --git a/packages/patapata_core/test/i18n_test.dart b/packages/patapata_core/test/i18n_test.dart new file mode 100644 index 0000000..eb47d41 --- /dev/null +++ b/packages/patapata_core/test/i18n_test.dart @@ -0,0 +1,370 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/src/exception.dart'; + +import 'pages/home_page.dart'; +import 'utils/patapata_core_test_utils.dart'; + +class _Environment { + const _Environment(); +} + +class _I18nEnvironment with I18nEnvironment { + const _I18nEnvironment(this._l10nPaths, this._supportedL10ns); + + final List? _l10nPaths; + + @override + List? get l10nPaths => _l10nPaths; + + final List? _supportedL10ns; + + @override + List? get supportedL10ns => _supportedL10ns; +} + +void main() { + group('class L10n', () { + setUp(() { + testInitialize(); + }); + + test( + 'Create L10n class.', + () async { + const tL10n = L10n( + Locale('en'), + { + 'aaa': 'Test', + }, + ); + + expect(tL10n.locale, equals(const Locale('en'))); + expect(tL10n.containsMessageKey('aaa'), isTrue); + expect(tL10n.lookup('aaa'), equals('Test')); + expect(tL10n.containsMessageKey('bbb'), isFalse); + expect(tL10n.lookup('bbb'), equals('bbb')); + }, + ); + + test( + 'The resource is loaded from assets and the lookup works correctly.', + () async { + final tL10nEn = await L10n.fromAssets( + locale: const Locale('en'), + paths: ['l10n'], + assetBundle: mockL10nAssetBundle, + ); + final tL10nJa = await L10n.fromAssets( + locale: const Locale('ja'), + paths: ['l10n'], + assetBundle: mockL10nAssetBundle, + ); + + // en + expect(tL10nEn.locale, equals(const Locale('en'))); + expect(tL10nEn.lookup('home.title'), equals('HomePage')); + expect(tL10nEn.containsMessageKey('test.title'), isTrue); + expect( + tL10nEn.lookup( + 'test.title', + namedParameters: {'param': 'en'}, + ), + equals('TestMessage:en'), + ); + expect(tL10nEn.containsMessageKey('test2.title'), isFalse); + expect( + tL10nEn.lookup( + 'test2.title', + namedParameters: {'param': 'en'}, + ), + equals('test2.title'), + ); + + // ja + expect(tL10nJa.locale, equals(const Locale('ja'))); + expect(tL10nJa.lookup('home.title'), equals('ホーム')); + expect(tL10nJa.containsMessageKey('test.title'), isTrue); + expect( + tL10nJa.lookup( + 'test.title', + namedParameters: {'param': 'ja'}, + ), + equals('テストメッセージ:ja'), + ); + expect(tL10nJa.containsMessageKey('test2.title'), isFalse); + expect( + tL10nJa.lookup( + 'test2.title', + namedParameters: {'param': 'ja'}, + ), + equals('test2.title'), + ); + }, + ); + + test( + 'If multiple paths are specified, the same key will be overwritten by subsequent files.', + () async { + final tL10nEn = await L10n.fromAssets( + locale: const Locale('en'), + paths: ['l10n', 'l10n2'], + assetBundle: mockL10nAssetBundle, + ); + final tL10nJa = await L10n.fromAssets( + locale: const Locale('ja'), + paths: ['l10n', 'l10n2'], + assetBundle: mockL10nAssetBundle, + ); + + // en + expect(tL10nEn.lookup('home.title'), equals('HomePage2')); + expect(tL10nEn.containsMessageKey('test.title'), isTrue); + expect( + tL10nEn.lookup( + 'test.title', + namedParameters: {'param': 'en'}, + ), + equals('TestMessage:en'), + ); + expect(tL10nEn.containsMessageKey('test2.title'), isTrue); + expect( + tL10nEn.lookup( + 'test2.title', + namedParameters: {'param': 'en'}, + ), + equals('TestMessage2:en'), + ); + + // ja + expect(tL10nJa.lookup('home.title'), equals('ホーム2')); + expect(tL10nJa.containsMessageKey('test.title'), isTrue); + expect( + tL10nJa.lookup( + 'test.title', + namedParameters: {'param': 'ja'}, + ), + equals('テストメッセージ:ja'), + ); + expect(tL10nJa.containsMessageKey('test2.title'), isTrue); + expect( + tL10nJa.lookup( + 'test2.title', + namedParameters: {'param': 'ja'}, + ), + equals('テストメッセージ2:ja'), + ); + }, + ); + test( + 'Load error in assets. The `patapata_dummy_never` is set as dummy.', + () async { + // The path is invalid and will not be loaded. + final tL10nNotFound = await L10n.fromAssets( + locale: const Locale('en'), + paths: ['aaa'], + assetBundle: mockL10nAssetBundle, + ); + + // Languages not supported by the app are not loaded. + final tL10nAr = await L10n.fromAssets( + locale: const Locale('ar'), + paths: ['l10n'], + assetBundle: mockL10nAssetBundle, + ); + + // Since [assetBundle] is not specified, the assets is loaded from [rootBundle], + // but in the test environment, the actual assets does not exist. + final tL10nEn = await L10n.fromAssets( + locale: const Locale('en'), + paths: ['l10n'], + ); + + expect(tL10nNotFound.containsMessageKey('home.title'), isFalse); + expect(tL10nNotFound.lookup('patapata_dummy_never'), 'dummy'); + expect(tL10nAr.containsMessageKey('home.title'), isFalse); + expect(tL10nAr.lookup('patapata_dummy_never'), 'dummy'); + expect(tL10nEn.containsMessageKey('home.title'), isFalse); + expect(tL10nEn.lookup('patapata_dummy_never'), 'dummy'); + }, + ); + + test( + 'Parse error in yaml.', + () async { + Object? tException; + final tLogSubscription = + Logger.root.onRecord.listen((LogRecord record) { + tException = record.error; + }); + + final tL10nError = await L10n.fromAssets( + locale: const Locale('en'), + paths: ['parse_error'], + assetBundle: mockL10nAssetBundle, + ); + + expect(tL10nError.containsMessageKey('home.title'), isFalse); + expect(tL10nError.containsMessageKey('patapata_dummy_never'), isFalse); + expect(tException, isA()); + expect((tException as L10nLoadAssetsException).code, + equals(PatapataCoreExceptionCode.PPE401.name)); + + // Empty yaml file + tException = null; + final tL10nEmpty = await L10n.fromAssets( + locale: const Locale('en'), + paths: ['empty'], + assetBundle: mockL10nAssetBundle, + ); + + expect(tL10nEmpty.containsMessageKey('home.title'), isFalse); + expect(tL10nEmpty.containsMessageKey('patapata_dummy_never'), isFalse); + expect(tException, isA()); + expect((tException as L10nLoadAssetsException).code, + equals(PatapataCoreExceptionCode.PPE401.name)); + + tLogSubscription.cancel(); + }, + ); + }); + + group('Initialize I18nPlugin and load assets.', () { + test( + "If I18nEnvironment is not specified, Locale('en') is read from l10n as default.", + () async { + final tApp = createApp(environment: const _Environment()); + final tI18nPlugin = I18nPlugin(); + const tLocale = Locale('en'); + + final bool tResult = await tI18nPlugin.init(tApp); + expect(tResult, isTrue); + expect(tI18nPlugin.i18n.supportedL10ns, [tLocale]); + expect(tI18nPlugin.i18n.delegate.isSupported(tLocale), isTrue); + expect( + tI18nPlugin.i18n.delegate.isSupported(const Locale('ja')), isFalse); + expect(tI18nPlugin.i18n.delegate.l10n, isNull); + await tI18nPlugin.i18n.delegate.load(tLocale); + expect(tI18nPlugin.i18n.delegate.l10n, isNotNull); + expect(tI18nPlugin.i18n.delegate.l10n!.containsMessageKey('test.title'), + isTrue); + }, + ); + + test( + 'If I18nEnvironment.l10nPaths is not set, `l10n` will be the default.', + () async { + final tApp = createApp( + environment: const _I18nEnvironment(null, [Locale('en')])); + final tI18nPlugin = I18nPlugin(); + const tLocale = Locale('en'); + + final bool tResult = await tI18nPlugin.init(tApp); + expect(tResult, isTrue); + await tI18nPlugin.i18n.delegate.load(tLocale); + expect(tI18nPlugin.i18n.delegate.l10n!.containsMessageKey('test.title'), + isTrue); + }, + ); + + test( + 'If I18nEnvironment.supportedL10ns is required.', + () async { + final tApp = createApp(environment: const _I18nEnvironment(null, null)); + final tI18nPlugin = I18nPlugin(); + + final bool tResult = await tI18nPlugin.init(tApp); + expect(tResult, isFalse); + }, + ); + }); + + group( + 'Widget tests', + () { + late App tApp; + + setUp(() async { + tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => HomePage(), + ), + ], + ), + ); + }); + + testWidgets( + 'l function process correctly.', + (WidgetTester tester) async { + tApp.run(); + + await tApp.runProcess( + () async { + await tester.pumpAndSettle(); + + expect( + l(StandardMaterialApp.globalNavigatorContext!, 'home.title'), + equals('HomePage'), + ); + expect( + l( + StandardMaterialApp.globalNavigatorContext!, + 'test.title', + { + 'param': 'test', + }, + ), + equals('TestMessage:test'), + ); + }, + ); + + tApp.dispose(); + }, + ); + + testWidgets( + 'L10n.containsKey process correctly.', + (WidgetTester tester) async { + tApp.run(); + + await tApp.runProcess( + () async { + await tester.pumpAndSettle(); + + expect( + L10n.containsKey( + context: StandardMaterialApp.globalNavigatorContext!, + key: 'home.title', + ), + isTrue, + ); + expect( + L10n.containsKey( + context: StandardMaterialApp.globalNavigatorContext!, + key: 'test2.title', + ), + isFalse, + ); + }, + ); + + tApp.dispose(); + }, + ); + }, + ); +} diff --git a/packages/patapata_core/test/local_config_plugin_test.dart b/packages/patapata_core/test/local_config_plugin_test.dart new file mode 100644 index 0000000..1af205e --- /dev/null +++ b/packages/patapata_core/test/local_config_plugin_test.dart @@ -0,0 +1,52 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/src/native_local_config_finder.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LocalConfigFinder', () { + test('factory LocalConfigFinder', () async { + final tLocalConfigFinder = LocalConfigFinder(); + expect(tLocalConfigFinder.runtimeType, NativeLocalConfigFinder); + }); + + test('getLocalConfig', () async { + final tLocalConfigFinder = LocalConfigFinder(); + LocalConfig? tLocalConfig = tLocalConfigFinder.getLocalConfig(); + + expect(tLocalConfig, isNotNull); + expect(tLocalConfig.runtimeType, NativeLocalConfig); + }); + }); + + group('NativeLocalConfigPlugin', () { + test('init | dispose', () async { + final NativeLocalConfigPlugin tNativeLocalConfigPlugin = + NativeLocalConfigPlugin(); + + final tApp = createApp(); + + await tNativeLocalConfigPlugin.init(tApp); + expect(tNativeLocalConfigPlugin.initialized, isTrue); + + tNativeLocalConfigPlugin.dispose(); + expect(tNativeLocalConfigPlugin.disposed, isTrue); + }); + + test('createLocalConfig', () async { + final NativeLocalConfigPlugin tNativeLocalConfigPlugin = + NativeLocalConfigPlugin(); + + final tNativaLocalConfig = tNativeLocalConfigPlugin.createLocalConfig(); + expect(tNativaLocalConfig.runtimeType, NativeLocalConfig); + }); + }); +} diff --git a/packages/patapata_core/test/log_test.dart b/packages/patapata_core/test/log_test.dart new file mode 100644 index 0000000..61681ae --- /dev/null +++ b/packages/patapata_core/test/log_test.dart @@ -0,0 +1,1390 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:stack_trace/stack_trace.dart'; + +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +final _logger = Logger('test.log'); + +class TestLogEnvironment with LogEnvironment { + const TestLogEnvironment({ + int? logLevel, + bool? printLog, + }) : _logLevel = logLevel, + _printLog = printLog; + + final int? _logLevel; + final bool? _printLog; + + @override + int get logLevel => _logLevel ?? Level.INFO.value; + + @override + bool get printLog => _printLog ?? super.printLog; +} + +class TestPatapataException extends PatapataException { + const TestPatapataException({ + required void Function(ReportRecord) onReported, + }) : _onReported = onReported; + + final void Function(ReportRecord) _onReported; + + @override + void onReported(ReportRecord record) { + _onReported(record); + } + + @override + String get defaultPrefix => 'LOG'; + + @override + String get internalCode => '000'; + + @override + String get namespace => 'test'; +} + +void main() { + testWidgets('ReportRecord constructor.', (WidgetTester tester) async { + ReportRecord tRecord; + StackTrace? tStackTrace; + try { + throw Exception('dummy'); + } catch (e, stackTrace) { + tStackTrace = stackTrace; + tRecord = ReportRecord( + level: Level.SEVERE, + message: 'message', + error: 'error', + object: 'object', + stackTrace: tStackTrace, + ); + } + expect(tRecord.level, equals(Level.SEVERE)); + expect(tRecord.message, equals('message')); + expect(tRecord.error, equals('error')); + expect(tRecord.object, equals('object')); + expect(tRecord.stackTrace, equals(tStackTrace)); + + // stackTrace from Error. + Error? tError; + try { + // throw Error(); + Object? tObject; + tObject!.toString(); + } catch (e, stackTrace) { + expect(e, isA()); + tError = e as Error; + tStackTrace = stackTrace; + tRecord = ReportRecord( + level: Level.SEVERE, + message: 'message', + error: e, + object: 'object', + ); + } + expect(tRecord.level, equals(Level.SEVERE)); + expect(tRecord.message, equals('message')); + expect(tRecord.error, equals(tError)); + expect(tRecord.object, equals('object')); + expect(tRecord.stackTrace, equals(tStackTrace)); + }); + + testWidgets('ReportRecord copyWith.', (WidgetTester tester) async { + final tRecord = ReportRecord( + level: Level.SEVERE, + message: 'message', + error: 'error', + object: 'object', + ); + + final tRecordClone = tRecord.copyWith(); + + final tRecordCopy = tRecord.copyWith( + level: Level.INFO, + message: 'message2', + error: 'error2', + object: 'object2', + ); + + expect(tRecord, isNot(tRecordClone)); + expect(tRecord, isNot(equals(tRecordCopy))); + + expect(tRecord.level, equals(Level.SEVERE)); + expect(tRecord.message, equals('message')); + expect(tRecord.error, equals('error')); + expect(tRecord.object, equals('object')); + + expect(tRecordClone.level, equals(Level.SEVERE)); + expect(tRecordClone.message, equals('message')); + expect(tRecordClone.error, equals('error')); + expect(tRecordClone.object, equals('object')); + + expect(tRecordCopy.level, equals(Level.INFO)); + expect(tRecordCopy.message, equals('message2')); + expect(tRecordCopy.error, equals('error2')); + expect(tRecordCopy.object, equals('object2')); + }); + + testWidgets('Log Environment correctly.', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + printLog: true, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tApp.log.level, equals(Level.SEVERE)); + expect(tApp.log.logPrinting, isTrue); + }); + + tApp.dispose(); + }); + + testWidgets('level and logPrinting setter', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + tApp.log.level = Level.SEVERE; + tApp.log.logPrinting = true; + expect(tApp.log.level, equals(Level.SEVERE)); + expect(tApp.log.logPrinting, isTrue); + + tApp.log.level = Level.INFO; + tApp.log.logPrinting = false; + expect(tApp.log.level, equals(Level.INFO)); + expect(tApp.log.logPrinting, isFalse); + + tApp.log.setLevelByValue(111); + expect(tApp.log.level.value, equals(111)); + }); + + tApp.dispose(); + }); + + testWidgets('report test', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tReportedRecord = + ReportRecord(level: Level.SEVERE, message: 'test'); + final tNotReportedRecord = + ReportRecord(level: Level.INFO, message: 'test'); + expectLater( + tApp.log.reports, + emits(tReportedRecord), + ); + expectLater( + tApp.log.reports, + neverEmits(tNotReportedRecord), + ); + + tApp.log.report(tReportedRecord); + tApp.log.report(tNotReportedRecord); + }); + + tApp.dispose(); + }); + + testWidgets('Logger test', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tStream = tApp.log.reports.asyncMap((event) => event.message); + + expectLater( + tStream, + emits(equals('severe')), + ); + expectLater( + tStream, + neverEmits(equals('info')), + ); + + _logger.severe('severe'); + _logger.info('info'); + + _logger.severe(ReportRecord(level: Level.SEVERE, message: 'test')); + }); + + tApp.dispose(); + }); + + testWidgets('Logger object test', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tObject = Object(); + final tStream = tApp.log.reports.asyncMap((event) => event.object); + + expectLater( + tStream, + emits(equals(tObject)), + ); + + _logger.severe( + ReportRecord(level: Level.SEVERE, message: 'test', object: tObject)); + }); + + tApp.dispose(); + }); + + testWidgets('logPrinting from Logger', (WidgetTester tester) async { + final App tApp = createApp(); + + final tOriginalDebugPrint = debugPrint; + bool tDebugPrintCalled = false; + debugPrint = (String? message, {int? wrapWidth}) { + tDebugPrintCalled = true; + }; + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + final tStream = tApp.log.reports.asyncMap((event) => event.message); + expectLater( + tStream, + emits(equals('test')), + ); + + tApp.log.logPrinting = true; + _logger.severe('test'); + expect(tDebugPrintCalled, isTrue); + + tDebugPrintCalled = false; + tApp.log.logPrinting = false; + _logger.severe('test'); + expect(tDebugPrintCalled, isFalse); + + tApp.log.logPrinting = true; + _logger.severe('test'); + expect(tDebugPrintCalled, isTrue); + }); + + tApp.dispose(); + + debugPrint = tOriginalDebugPrint; + }); + + testWidgets('duplicate error test', (WidgetTester tester) async { + final App tApp = createApp(); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tExceptionA = Exception('duplicateObject'); + final tExceptionB = Exception('duplicateError'); + + final tStream = tApp.log.reports.asyncMap((event) => event.message); + expectLater( + tStream, + emitsInOrder([ + 'duplicateObject', + tExceptionB.toString(), + emitsDone, + ]), + ); + + try { + try { + throw tExceptionA; + } catch (e, stackTrace) { + _logger.severe('duplicateObject', e, stackTrace); + rethrow; + } + } catch (e, stackTrace) { + _logger.severe('duplicateObject', e, stackTrace); + } + + try { + try { + throw tExceptionB; + } catch (e) { + _logger.severe(e); + rethrow; + } + } catch (e) { + _logger.severe(e); + } + }); + + tApp.dispose(); + }); + + testWidgets('filter test', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + printLog: false, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tStream = tApp.log.reports.asyncMap((event) => event.message); + expectLater( + tStream, + emitsInOrder([ + 'severe', + 'removeIgnore', + ]), + ); + expectLater( + tStream, + neverEmits(equals('ignore')), + ); + + fFilter(ReportRecord record) { + if (record.message == 'ignore' || record.message == 'removeIgnore') { + return null; + } + return record; + } + + tApp.log.addFilter(fFilter); + + final tRecord = ReportRecord(level: Level.SEVERE, message: 'severe'); + final tRecordIgnore = + ReportRecord(level: Level.SEVERE, message: 'ignore'); + expect(tApp.log.filter(tRecord), equals(tRecord)); + expect(tApp.log.filter(tRecordIgnore), isNull); + + _logger.severe('severe'); + _logger.severe('ignore'); + + tApp.log.removeFilter(fFilter); + _logger.severe('removeIgnore'); + expect(tApp.log.filter(tRecord), equals(tRecord)); + expect(tApp.log.filter(tRecordIgnore), equals(tRecordIgnore)); + }); + + tApp.dispose(); + }); + + testWidgets('ignoreType test', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + printLog: false, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tStream = tApp.log.reports.asyncMap((event) => event.message); + expectLater( + tStream, + emitsInOrder([ + 'severe', + 'removeIgnore', + ]), + ); + expectLater( + tStream, + neverEmits(equals('ignore')), + ); + + tApp.log.ignoreType(int); + + final tRecord = + ReportRecord(level: Level.SEVERE, message: 'severe', error: '1'); + final tRecordIgnore = + ReportRecord(level: Level.SEVERE, message: 'ignore', error: 1); + expect(tApp.log.filter(tRecord), equals(tRecord)); + expect(tApp.log.filter(tRecordIgnore), isNull); + + _logger.severe('severe', '1'); + _logger.severe('ignore', 1); + + tApp.log.unignoreType(int); + _logger.severe('removeIgnore', 1); + expect(tApp.log.filter(tRecord), equals(tRecord)); + expect(tApp.log.filter(tRecordIgnore), equals(tRecordIgnore)); + }); + + tApp.dispose(); + }); + + testWidgets('Report PatapataException test', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + printLog: false, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tStream = tApp.log.reports.asyncMap((event) => event.message); + expectLater( + tStream, + emits(equals('patapataException')), + ); + + bool tOnReportedCalled = false; + + try { + throw TestPatapataException( + onReported: (record) { + tOnReportedCalled = true; + throw 'This exception must not propagate.'; + }, + ); + } catch (e, stackTrace) { + expect(e, isA()); + _logger.severe('patapataException', e, stackTrace); + } + + await tester.pumpAndSettle(); + + expect(tOnReportedCalled, isTrue); + }); + + tApp.dispose(); + }); + + testWidgets('Log FlutterError', (WidgetTester tester) async { + final tOriginalOnError = FlutterError.onError; + bool tOnErrorCalled = false; + FlutterError.onError = (details) { + tOnErrorCalled = true; + }; + + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + printLog: false, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tStream = tApp.log.reports.asyncMap((event) => event.object); + expectLater( + tStream, + emits(isA()), + ); + + // throw FlutterError + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + ListView( + children: const [ + Text('Exception!'), + ], + ), + ], + ), + ), + ); + await tester.takeException(); + + expect(tOnErrorCalled, isTrue); + + FlutterError.onError = tOriginalOnError; + }); + + tApp.dispose(); + }); + + testWidgets('RemoteConfig LogLevel test.', (WidgetTester tester) async { + final Map tStore = {}; + final tMockRemoteConfig = MockRemoteConfig(tStore); + + final App tApp = createApp(plugins: [ + Plugin.inline( + createRemoteConfig: () => tMockRemoteConfig, + ), + ]); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tMockRemoteConfig.setInt('patapata_log_level', 111); + await tMockRemoteConfig.fetch(force: true); + + expect(tApp.log.level.value, equals(111)); + }); + + tApp.dispose(); + }); + + testWidgets('RemoteConfig levelFilter test.', (WidgetTester tester) async { + final Map tStore = {}; + final tMockRemoteConfig = MockRemoteConfig(tStore); + + final App tApp = createApp(plugins: [ + Plugin.inline( + createRemoteConfig: () => tMockRemoteConfig, + ), + ]); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tMockRemoteConfig.setString(Log.kRemoteConfigLevelFilters, + '{"info":888, "severe":1111, "fine":500}'); + await tMockRemoteConfig.fetch(force: true); + + final tReportInfo = ReportRecord(level: Level.INFO, message: 'info'); + final tReportSever = ReportRecord(level: Level.INFO, message: 'severe'); + final tReportInfoToFine = + ReportRecord(level: Level.INFO, message: 'fine'); + + final tInfo = tApp.log.filter(tReportInfo); + final tSever = tApp.log.filter(tReportSever); + final tInfoToFine = tApp.log.filter(tReportInfoToFine); + expect(tInfo?.level.value, equals(888)); + expect(tSever?.level.value, equals(1111)); + expect(tInfoToFine?.level, equals(Level.FINE)); + + final tReportObjectInfo = + ReportRecord(level: Level.INFO, message: 'test', error: 'info'); + final tReportErrorInfo = + ReportRecord(level: Level.INFO, message: 'test', object: 'info'); + + final tObjectInfo = tApp.log.filter(tReportObjectInfo); + final tErrorInfo = tApp.log.filter(tReportErrorInfo); + expect(tObjectInfo?.level.value, equals(888)); + expect(tErrorInfo?.level.value, equals(888)); + }); + + tApp.dispose(); + }); + + testWidgets('RemoteConfig levelFilter parsing failed.', + (WidgetTester tester) async { + final tOriginalDebugPrint = debugPrint; + + bool tPrintParsingError = false; + debugPrint = (String? message, {int? wrapWidth}) { + if (message?.contains('RemoteConfig ${Log.kRemoteConfigLevelFilters}') == + true) { + tPrintParsingError = true; + } + }; + + final Map tStore = {}; + final tMockRemoteConfig = MockRemoteConfig(tStore); + + final App tApp = createApp(plugins: [ + Plugin.inline( + createRemoteConfig: () => tMockRemoteConfig, + ), + ]); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + tApp.log.logPrinting = true; + + await tMockRemoteConfig.setString( + Log.kRemoteConfigLevelFilters, '{"info":888,'); + await tMockRemoteConfig.fetch(force: true); + }); + + expect(tPrintParsingError, isTrue); + + tApp.dispose(); + + debugPrint = tOriginalDebugPrint; + }); + + testWidgets('Logger large message print', (WidgetTester tester) async { + // '[SEVERE] test.log: ' + 1007bytes = 1026bytes + const tLargeMessageA = + 'testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestEND'; + // '[SEVERE] test.log: ' + 1006bytes = 1025bytes + const tLargeMessageB = + 'testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesあいう'; + + final tOriginalDebugPrint = debugPrint; + final List tPrintMessage = []; + debugPrint = (String? message, {int? wrapWidth}) { + tPrintMessage.add(message!); + }; + + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.SEVERE.value, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + _logger.severe(tLargeMessageA); + + expect(tPrintMessage.length, equals(2)); + expect(tPrintMessage[0].length, equals(1023)); + expect(tPrintMessage[1], equals('END')); + + tPrintMessage.clear(); + + _logger.severe(tLargeMessageB); + expect(tPrintMessage.length, equals(2)); + expect(tPrintMessage[0].length, equals(1022)); + expect(tPrintMessage[1], equals('あいう')); + }); + + tApp.dispose(); + + debugPrint = tOriginalDebugPrint; + }); + + test('NativeThrowble for Android.', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final tThrowableMap = { + 'type': 'java.lang.AAA', + 'message': 'throwbleA', + 'stackTrace': [ + 'java.lang.Integer.parseInt(Integer.java:797)', + 'java.lang.Integer.parseInt(Integer.java:915)', + 'dev.patapata.patapata_core_example.MainActivity.configureFlutterEngine\$lambda-1\$lambda-0(MainActivity.kt:27)', + 'dev.patapata.patapata_core_example.MainActivity.\$r8\$lambda\$mgziiATvBKRngKgviCJADp8PLSA(Unknown Source:0)', + 'dev.patapata.patapata_core_example.MainActivity\$\$ExternalSyntheticLambda0.onMethodCall(Unknown Source:2)', + 'io.flutter.plugin.common.MethodChannel\$IncomingMethodCallHandler.onMessage(MethodChannel.java:258)', + 'io.flutter.embedding.engine.dart.DartMessenger.invokeHandler(DartMessenger.java:295)', + 'io.flutter.embedding.engine.dart.DartMessenger.lambda\$dispatchMessageToQueue\$0\$io-flutter-embedding-engine-dart-DartMessenger(DartMessenger.java:322)', + 'io.flutter.embedding.engine.dart.DartMessenger\$\$ExternalSyntheticLambda0.run(Unknown Source:12)', + 'android.os.Handler.handleCallback(Handler.java:942)', + 'android.os.Handler.dispatchMessage(Handler.java:99)', + 'android.os.Looper.loopOnce(Looper.java:346)', + 'android.os.Looper.loop(Looper.java:475)', + 'android.app.ActivityThread.main(ActivityThread.java:7950)', + 'java.lang.reflect.Method.invoke(Native Method)', + 'com.android.internal.os.RuntimeInit\$MethodAndArgsCaller.run(RuntimeInit.java:548)', + 'com.android.internal.os.ZygoteInit.main(ZygoteInit.java:942)', + ], + 'cause': { + 'type': 'java.lang.BBB', + 'message': 'throwbleB', + 'stackTrace': [ + 'java.lang.Integer.parseInt(Integer.java:4)', + 'java.lang.Integer.parseInt(Integer.java:5)', + 'dev.patapata.patapata_core_example.MainActivity.test(MainActivity.kt:6)', + ], + 'cause': null, + }, + }; + final tExpectTraces = [ + Trace( + [ + Frame( + Uri.file('java.lang/Integer.java'), + 797, + null, + 'Integer.parseInt', + ), + Frame( + Uri.file('java.lang/Integer.java'), + 915, + null, + 'Integer.parseInt', + ), + Frame( + Uri.file('dev.patapata.patapata_core_example/MainActivity.kt'), + 27, + null, + 'MainActivity.configureFlutterEngine\$lambda-1\$lambda-0', + ), + Frame( + Uri.file('dev.patapata.patapata_core_example/UnknownSource'), + 0, + null, + 'MainActivity.\$r8\$lambda\$mgziiATvBKRngKgviCJADp8PLSA', + ), + Frame( + Uri.file('dev.patapata.patapata_core_example/UnknownSource'), + 2, + null, + 'MainActivity\$\$ExternalSyntheticLambda0.onMethodCall', + ), + Frame( + Uri.file('io.flutter.plugin.common/MethodChannel.java'), + 258, + null, + 'MethodChannel\$IncomingMethodCallHandler.onMessage', + ), + Frame( + Uri.file('io.flutter.embedding.engine.dart/DartMessenger.java'), + 295, + null, + 'DartMessenger.invokeHandler', + ), + Frame( + Uri.file('io.flutter.embedding.engine.dart/DartMessenger.java'), + 322, + null, + 'DartMessenger.lambda\$dispatchMessageToQueue\$0\$io-flutter-embedding-engine-dart-DartMessenger', + ), + Frame( + Uri.file('io.flutter.embedding.engine.dart/UnknownSource'), + 12, + null, + 'DartMessenger\$\$ExternalSyntheticLambda0.run', + ), + Frame( + Uri.file('android.os/Handler.java'), + 942, + null, + 'Handler.handleCallback', + ), + Frame( + Uri.file('android.os/Handler.java'), + 99, + null, + 'Handler.dispatchMessage', + ), + Frame( + Uri.file('android.os/Looper.java'), + 346, + null, + 'Looper.loopOnce', + ), + Frame( + Uri.file('android.os/Looper.java'), + 475, + null, + 'Looper.loop', + ), + Frame( + Uri.file('android.app/ActivityThread.java'), + 7950, + null, + 'ActivityThread.main', + ), + Frame( + Uri.file('java.lang.reflect/NativeMethod'), + null, + null, + 'Method.invoke', + ), + Frame( + Uri.file('com.android.internal.os/RuntimeInit.java'), + 548, + null, + 'RuntimeInit\$MethodAndArgsCaller.run', + ), + Frame( + Uri.file('com.android.internal.os/ZygoteInit.java'), + 942, + null, + 'ZygoteInit.main', + ), + ], + ), + Trace( + [ + Frame( + Uri.file('java.lang/Integer.java'), + 4, + null, + 'Integer.parseInt', + ), + Frame( + Uri.file('java.lang/Integer.java'), + 5, + null, + 'Integer.parseInt', + ), + Frame( + Uri.file('dev.patapata.patapata_core_example/MainActivity.kt'), + 6, + null, + 'MainActivity.test', + ), + ], + ), + ]; + final tTestNativeThrowable = NativeThrowable.fromMap(tThrowableMap); + final tTestCause = tTestNativeThrowable.cause!; + expect(tTestNativeThrowable.type, equals('java.lang.AAA')); + expect(tTestNativeThrowable.message, equals('throwbleA')); + expect(tTestCause.type, equals('java.lang.BBB')); + expect(tTestCause.message, equals('throwbleB')); + expect(tTestCause.cause, isNull); + + expect(tTestNativeThrowable.chain!.traces.length, equals(2)); + int tTraceIndex = 0; + for (var traces in tTestNativeThrowable.chain!.traces) { + int tFrameIndex = 0; + for (var frame in traces.frames) { + final tExpectFrame = tExpectTraces[tTraceIndex].frames[tFrameIndex]; + expect(frame.uri, equals(tExpectFrame.uri)); + expect(frame.line, equals(tExpectFrame.line)); + expect(frame.column, equals(tExpectFrame.column)); + expect(frame.member, equals(tExpectFrame.member)); + tFrameIndex++; + } + tTraceIndex++; + } + + expect(tTestCause.chain!.traces.length, equals(1)); + int tFrameIndex = 0; + for (var frame in tTestCause.chain!.traces.first.frames) { + final tExpectFrame = tExpectTraces[1].frames[tFrameIndex]; + expect(frame.uri, equals(tExpectFrame.uri)); + expect(frame.line, equals(tExpectFrame.line)); + expect(frame.column, equals(tExpectFrame.column)); + expect(frame.member, equals(tExpectFrame.member)); + tFrameIndex++; + } + tTraceIndex++; + + expect(tTestNativeThrowable.toMap(), equals(tThrowableMap)); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Native logging level map.', (WidgetTester tester) async { + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.ALL.value, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + // Mock Native logging. + const tChannelName = 'patapata/test/log'; + const tChannel = MethodChannel(tChannelName); + Future fNativeLogging(Map arguments) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + tChannelName, + tChannel.codec.encodeMethodCall( + MethodCall( + 'logging', + arguments, + ), + ), + (data) {}, + ); + } + + NativeThrowable.registerNativeThrowableMethodChannel(tChannelName); + + final tNativeLoggerLevelMap = { + 0: Level('EMERGENCY', Level.SHOUT.value), + 1: Level('ALERT', Level.SHOUT.value - 10), + 2: Level('CRITICAL', Level.SHOUT.value - 20), + 3: Level('ERROR', Level.SEVERE.value), + 4: Level.WARNING, + 5: Level('NOTICE', Level.INFO.value + 10), + 6: Level.INFO, + 7: Level('DEBUG', Level.FINE.value), + }; + + final tStream = tApp.log.reports.asyncMap((event) => event.level.value); + expectLater( + tStream, + emitsInOrder([ + Level.INFO.value, + tNativeLoggerLevelMap[0]!.value, + tNativeLoggerLevelMap[1]!.value, + tNativeLoggerLevelMap[2]!.value, + tNativeLoggerLevelMap[3]!.value, + tNativeLoggerLevelMap[4]!.value, + tNativeLoggerLevelMap[5]!.value, + tNativeLoggerLevelMap[6]!.value, + tNativeLoggerLevelMap[7]!.value, + ]), + ); + + // default level + await fNativeLogging({ + 'message': 'LogMessage', + }); + + // level map + for (var entry in tNativeLoggerLevelMap.entries) { + await fNativeLogging({ + 'level': entry.key, + 'message': 'LogMessage', + }); + } + + NativeThrowable.unregisterNativeThrowableMethodChannel(tChannelName); + }); + + tApp.dispose(); + }); + + testWidgets('Native logging for Android.', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.ALL.value, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + // Mock Native logging. + const tChannelName = 'patapata/test/log'; + const tChannel = MethodChannel(tChannelName); + Future fNativeLogging(Map arguments) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + tChannelName, + tChannel.codec.encodeMethodCall( + MethodCall( + 'logging', + arguments, + ), + ), + (data) {}, + ); + } + + NativeThrowable.registerNativeThrowableMethodChannel(tChannelName); + + final tThrowableMap = { + 'type': 'java.lang.AAA', + 'message': 'throwbleA', + 'stackTrace': [ + 'java.lang.Integer.parseInt(Integer.java:797)', + 'java.lang.Integer.parseInt(Integer.java:915)', + 'dev.patapata.patapata_core_example.MainActivity.configureFlutterEngine\$lambda-1\$lambda-0(MainActivity.kt:27)', + 'dev.patapata.patapata_core_example.MainActivity.\$r8\$lambda\$mgziiATvBKRngKgviCJADp8PLSA(Unknown Source:0)', + 'dev.patapata.patapata_core_example.MainActivity\$\$ExternalSyntheticLambda0.onMethodCall(Unknown Source:2)', + 'io.flutter.plugin.common.MethodChannel\$IncomingMethodCallHandler.onMessage(MethodChannel.java:258)', + 'io.flutter.embedding.engine.dart.DartMessenger.invokeHandler(DartMessenger.java:295)', + 'io.flutter.embedding.engine.dart.DartMessenger.lambda\$dispatchMessageToQueue\$0\$io-flutter-embedding-engine-dart-DartMessenger(DartMessenger.java:322)', + 'io.flutter.embedding.engine.dart.DartMessenger\$\$ExternalSyntheticLambda0.run(Unknown Source:12)', + 'android.os.Handler.handleCallback(Handler.java:942)', + 'android.os.Handler.dispatchMessage(Handler.java:99)', + 'android.os.Looper.loopOnce(Looper.java:346)', + 'android.os.Looper.loop(Looper.java:475)', + 'android.app.ActivityThread.main(ActivityThread.java:7950)', + 'java.lang.reflect.Method.invoke(Native Method)', + 'com.android.internal.os.RuntimeInit\$MethodAndArgsCaller.run(RuntimeInit.java:548)', + 'com.android.internal.os.ZygoteInit.main(ZygoteInit.java:942)', + ], + 'cause': { + 'type': 'java.lang.BBB', + 'message': 'throwbleB', + 'stackTrace': [ + 'java.lang.Integer.parseInt(Integer.java:4)', + 'java.lang.Integer.parseInt(Integer.java:5)', + 'dev.patapata.patapata_core_example.MainActivity.test(MainActivity.kt:6)', + ], + 'cause': null, + }, + }; + + var tStream = tApp.log.reports.first; + + await fNativeLogging({ + 'level': 6, + 'message': 'LogMessage', + 'metadata': '{"foo": "bar"}', + 'timestamp': 1701930530000, + 'throwable': tThrowableMap, + }); + + var tReport = await tStream; + + expect(tReport.level, equals(Level.INFO)); + expect(tReport.message, equals('LogMessage')); + expect(tReport.extra, equals({'foo': 'bar'})); + expect(tReport.time.millisecondsSinceEpoch, equals(1701930530000)); + expect(tReport.object, isNull); + expect(tReport.error, isA()); + expect((tReport.error as NativeThrowable).toMap(), equals(tThrowableMap)); + expect( + tReport.stackTrace, equals((tReport.error as NativeThrowable).chain)); + + NativeThrowable.unregisterNativeThrowableMethodChannel(tChannelName); + }); + + tApp.dispose(); + + debugDefaultTargetPlatformOverride = null; + }); + + test('NativeThrowble for iOS.', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + final tThrowableMap = { + 'type': 'ErrorType', + 'message': 'throwbleA', + 'stackTrace': [ + '1 Runner 0x0000000000000000 \$s6Runner12NativeLoggerC14viewControllerACSo011FlutterViewE0C_tcfcySo0F10MethodCallC_yypSgctcACcfu_yAH_yAIctcfu0_ + 72', + '2 Runner 0x0000000000000000 \$sSo17FlutterMethodCallCypSgIegn_Ieggg_AByXlSgIeyBy_IeyByy_TR + 136', + '3 Flutter 0x0000000000000000 __45-[FlutterMethodChannel setMethodCallHandler:]_block_invoke + 172', + '4 Flutter 0x0000000000000000 ___ZN7flutter25PlatformMessageHandlerIos21HandlePlatformMessageENSt21_LIBCPP_ABI_NAMESPACE10unique_ptrINS_15PlatformMessageENS1_14default_deleteIS3_EEEE_block_invoke + 116', + '5 libdispatch.dylib 0x0000000000000000 959CD6E4-0CE7-3022-B73C-8B36F79F4745 + 7172', + '6 libdispatch.dylib 0x0000000000000000 959CD6E4-0CE7-3022-B73C-8B36F79F4745 + 14672', + '7 libdispatch.dylib 0x0000000000000000 _dispatch_main_queue_callback_4CF + 940', + '8 CoreFoundation 0x0000000000000000 6174789A-E88C-3F5C-BA39-DE2E9EDC0750 + 335076', + '9 CoreFoundation 0x0000000000000000 6174789A-E88C-3F5C-BA39-DE2E9EDC0750 + 48828', + '10 CoreFoundation 0x0000000000000000 CFRunLoopRunSpecific + 600', + '11 GraphicsServices 0x0000000000000000 GSEventRunModal + 164', + '12 UIKitCore 0x0000000000000000 0E2D8679-D5F1-3C03-9010-7F6CE3662789 + 5353660', + '13 UIKitCore 0x0000000000000000 UIApplicationMain + 2124', + '14 Runner 0x0000000000000000 main + 64', + '15 dyld 0x0000000000000000 start + 520', + ], + 'cause': null, + }; + final tExpectTrace = Trace( + [ + Frame( + Uri.file('Runner/'), + 72, + null, + '\$s6Runner12NativeLoggerC14viewControllerACSo011FlutterViewE0C_tcfcySo0F10MethodCallC_yypSgctcACcfu_yAH_yAIctcfu0_', + ), + Frame( + Uri.file('Runner/'), + 136, + null, + '\$sSo17FlutterMethodCallCypSgIegn_Ieggg_AByXlSgIeyBy_IeyByy_TR', + ), + Frame( + Uri.file('Flutter/'), + 172, + null, + '__45-[FlutterMethodChannel setMethodCallHandler:]_block_invoke', + ), + Frame( + Uri.file('Flutter/'), + 116, + null, + '___ZN7flutter25PlatformMessageHandlerIos21HandlePlatformMessageENSt21_LIBCPP_ABI_NAMESPACE10unique_ptrINS_15PlatformMessageENS1_14default_deleteIS3_EEEE_block_invoke', + ), + Frame( + Uri.file('libdispatch.dylib/'), + 7172, + null, + '959CD6E4-0CE7-3022-B73C-8B36F79F4745', + ), + Frame( + Uri.file('libdispatch.dylib/'), + 14672, + null, + '959CD6E4-0CE7-3022-B73C-8B36F79F4745', + ), + Frame( + Uri.file('libdispatch.dylib/'), + 940, + null, + '_dispatch_main_queue_callback_4CF', + ), + Frame( + Uri.file('CoreFoundation/'), + 335076, + null, + '6174789A-E88C-3F5C-BA39-DE2E9EDC0750', + ), + Frame( + Uri.file('CoreFoundation/'), + 48828, + null, + '6174789A-E88C-3F5C-BA39-DE2E9EDC0750', + ), + Frame( + Uri.file('CoreFoundation/'), + 600, + null, + 'CFRunLoopRunSpecific', + ), + Frame( + Uri.file('GraphicsServices/'), + 164, + null, + 'GSEventRunModal', + ), + Frame( + Uri.file('UIKitCore/'), + 5353660, + null, + '0E2D8679-D5F1-3C03-9010-7F6CE3662789', + ), + Frame( + Uri.file('UIKitCore/'), + 2124, + null, + 'UIApplicationMain', + ), + Frame( + Uri.file('Runner/'), + 64, + null, + 'main', + ), + Frame( + Uri.file('dyld/'), + 520, + null, + 'start', + ), + ], + ); + final tTestNativeThrowable = NativeThrowable.fromMap(tThrowableMap); + expect(tTestNativeThrowable.type, equals('ErrorType')); + expect(tTestNativeThrowable.message, equals('throwbleA')); + expect(tTestNativeThrowable.cause, isNull); + + expect(tTestNativeThrowable.chain!.traces.length, equals(1)); + int tFrameIndex = 0; + for (var frame in tTestNativeThrowable.chain!.traces.first.frames) { + final tExpectFrame = tExpectTrace.frames[tFrameIndex]; + expect(frame.uri, equals(tExpectFrame.uri)); + expect(frame.line, equals(tExpectFrame.line)); + expect(frame.column, equals(tExpectFrame.column)); + expect(frame.member, equals(tExpectFrame.member)); + tFrameIndex++; + } + + expect(tTestNativeThrowable.toMap(), equals(tThrowableMap)); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Native logging for iOS.', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + final App tApp = createApp( + environment: TestLogEnvironment( + logLevel: Level.ALL.value, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + // Mock Native logging. + const tChannelName = 'patapata/test/log'; + const tChannel = MethodChannel(tChannelName); + late DateTime tTimestamp; + Future fNativeLogging(Map arguments) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + tChannelName, + tChannel.codec.encodeMethodCall( + MethodCall( + 'logging', + arguments, + ), + ), + (data) { + tTimestamp = DateTime.now(); + }, + ); + } + + NativeThrowable.registerNativeThrowableMethodChannel(tChannelName); + + final tThrowableMap = { + 'type': 'ErrorType', + 'message': 'throwbleA', + 'stackTrace': [ + '1 Runner 0x0000000000000000 \$s6Runner12NativeLoggerC14viewControllerACSo011FlutterViewE0C_tcfcySo0F10MethodCallC_yypSgctcACcfu_yAH_yAIctcfu0_ + 72', + '2 Runner 0x0000000000000000 \$sSo17FlutterMethodCallCypSgIegn_Ieggg_AByXlSgIeyBy_IeyByy_TR + 136', + '3 Flutter 0x0000000000000000 __45-[FlutterMethodChannel setMethodCallHandler:]_block_invoke + 172', + '4 Flutter 0x0000000000000000 ___ZN7flutter25PlatformMessageHandlerIos21HandlePlatformMessageENSt21_LIBCPP_ABI_NAMESPACE10unique_ptrINS_15PlatformMessageENS1_14default_deleteIS3_EEEE_block_invoke + 116', + '5 libdispatch.dylib 0x0000000000000000 959CD6E4-0CE7-3022-B73C-8B36F79F4745 + 7172', + '6 libdispatch.dylib 0x0000000000000000 959CD6E4-0CE7-3022-B73C-8B36F79F4745 + 14672', + '7 libdispatch.dylib 0x0000000000000000 _dispatch_main_queue_callback_4CF + 940', + '8 CoreFoundation 0x0000000000000000 6174789A-E88C-3F5C-BA39-DE2E9EDC0750 + 335076', + '9 CoreFoundation 0x0000000000000000 6174789A-E88C-3F5C-BA39-DE2E9EDC0750 + 48828', + '10 CoreFoundation 0x0000000000000000 CFRunLoopRunSpecific + 600', + '11 GraphicsServices 0x0000000000000000 GSEventRunModal + 164', + '12 UIKitCore 0x0000000000000000 0E2D8679-D5F1-3C03-9010-7F6CE3662789 + 5353660', + '13 UIKitCore 0x0000000000000000 UIApplicationMain + 2124', + '14 Runner 0x0000000000000000 main + 64', + '15 dyld 0x0000000000000000 start + 520', + ], + 'cause': null, + }; + + var tStream = tApp.log.reports.first; + + await fNativeLogging({ + 'level': 6, + 'message': 'LogMessage', + 'metadata': '{"foo": "bar"}', + 'throwable': tThrowableMap, + }); + + var tReport = await tStream; + + expect(tReport.level, equals(Level.INFO)); + expect(tReport.message, equals('LogMessage')); + expect(tReport.extra, equals({'foo': 'bar'})); + expect(DateFormat('yyyyMMdd').format(tReport.time), + equals(DateFormat('yyyyMMdd').format(tTimestamp))); + expect(tReport.object, isNull); + expect(tReport.error, isA()); + expect((tReport.error as NativeThrowable).toMap(), equals(tThrowableMap)); + expect( + tReport.stackTrace, equals((tReport.error as NativeThrowable).chain)); + + NativeThrowable.unregisterNativeThrowableMethodChannel(tChannelName); + }); + + tApp.dispose(); + + debugDefaultTargetPlatformOverride = null; + }); + + test('NativeThrowble unsupported platform.', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + + final tThrowableMap = { + 'type': 'java.lang.AAA', + 'message': 'throwbleA', + 'stackTrace': [ + 'java.lang.Integer.parseInt(Integer.java:4)', + ], + 'cause': null, + }; + final tExpectFrame = + Frame.parseFriendly('java.lang.Integer.parseInt(Integer.java:4)'); + + final tTestNativeThrowable = NativeThrowable.fromMap(tThrowableMap); + + expect(tTestNativeThrowable.type, equals('java.lang.AAA')); + expect(tTestNativeThrowable.message, equals('throwbleA')); + expect(tTestNativeThrowable.cause, isNull); + expect(tTestNativeThrowable.chain!.traces.length, equals(1)); + expect(tTestNativeThrowable.chain!.traces.first.frames.first.toString(), + equals(tExpectFrame.toString())); + expect( + tTestNativeThrowable.toMap(), + equals({ + 'type': 'java.lang.AAA', + 'message': 'throwbleA', + 'stackTrace': [ + tExpectFrame.toString(), + ], + 'cause': null, + }), + ); + + debugDefaultTargetPlatformOverride = null; + }); + + test('NativeThrowble stackTrace parse error.', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + final tThrowableMap = { + 'type': 'java.lang.AAA', + 'message': 'throwbleA', + 'stackTrace': [ + 'aaa', + ], + 'cause': null, + }; + final tExpectFrame = Frame.parseFriendly('aaa'); + + final tTestNativeThrowable = NativeThrowable.fromMap(tThrowableMap); + + expect(tTestNativeThrowable.type, equals('java.lang.AAA')); + expect(tTestNativeThrowable.message, equals('throwbleA')); + expect(tTestNativeThrowable.cause, isNull); + expect(tTestNativeThrowable.chain!.traces.length, equals(1)); + expect(tTestNativeThrowable.chain!.traces.first.frames.first.toString(), + equals(tExpectFrame.toString())); + expect( + tTestNativeThrowable.toMap(), + equals({ + 'type': 'java.lang.AAA', + 'message': 'throwbleA', + 'stackTrace': [ + tExpectFrame.toString(), + ], + 'cause': null, + }), + ); + + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/packages/patapata_core/test/logic_state_test.dart b/packages/patapata_core/test/logic_state_test.dart new file mode 100644 index 0000000..c15db3d --- /dev/null +++ b/packages/patapata_core/test/logic_state_test.dart @@ -0,0 +1,525 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/src/exception.dart'; + +void main() { + test('A new machine should be in the first state after instantiation.', () { + expect( + LogicStateMachine([ + LogicStateFactory(() => StateA(), []), + ]).current, + isInstanceOf(), + ); + }); + + test('LogicStateMachine.toString', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), []), + ]); + tMachine.current.onComplete.ignore(); + + expect(tMachine.toString(), + 'LogicStateMachine: complete=false, error=null, current=${tMachine.current.runtimeType}: initialized=true, active=true'); + + tMachine.current.complete(); + + expect(tMachine.toString(), + 'LogicStateMachine: complete=true, error=null, current=${tMachine.current.runtimeType}: initialized=true, active=false'); + }); + + test('A state can transition to another instance of itself', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + ]); + + final tFirstInstance = tMachine.current; + tMachine.current.complete(); + + expect( + tMachine.current, + isInstanceOf(), + ); + + expect(tMachine.current, isNot(equals(tFirstInstance))); + }); + + test('A machine should be complete when the last state completes', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), []), + ]); + + tMachine.current.complete(); + + expect( + tMachine.current, + isInstanceOf(), + ); + + expect(tMachine.complete, isTrue); + }); + + test( + 'A LogicStateTransitionNotFound should be thrown when a state class in a transition is attempted to transition to, where that state does not exist in the machine', + () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + ]); + + expect( + () => tMachine.current.complete(), + throwsA(isA()), + ); + }); + + test('A state can transition to another state', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), []), + ]); + + tMachine.current.complete(); + + expect( + tMachine.current, + isInstanceOf(), + ); + }); + + test('A state can transition to another state, and then another', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateC(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), [ + LogicStateTransition(), + ]), + ]); + + tMachine.current.complete(); + tMachine.current.complete(); + + expect( + tMachine.current, + isInstanceOf(), + ); + }); + + test('A state can fail to transition via delegate failure', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), [ + LogicStateTransition( + ((LogicState current, LogicState next) => false)), + ]), + LogicStateFactory(() => StateB(), []), + ]); + + expect( + () => tMachine.current.complete(), + throwsA(isA()), + ); + }); + + test('LogicStateAllTransitionsNotAllowed.toString', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), [ + LogicStateTransition( + ((LogicState current, LogicState next) => false)), + ]), + LogicStateFactory(() => StateB(), []), + ]); + + late LogicStateAllTransitionsNotAllowed tError; + try { + tMachine.current.complete(); + } catch (e) { + tError = e as LogicStateAllTransitionsNotAllowed; + } + + expect( + tError.toString(), + equals( + 'LogicStateAllTransitionsNotAllowed: ${tError.current.toString()} is not allowed to transition to anything.'), + ); + }); + + test('A new machine must have at least one state.', () { + expect(() => LogicStateMachine([]), throwsAssertionError); + }); + + test('A state can transition itself to a specific other state', () { + expect( + LogicStateMachine([ + LogicStateFactory(() => StateXToA(), [ + LogicStateTransition(), + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), []), + LogicStateFactory(() => StateA(), []), + ]).current, + isInstanceOf(), + ); + }); + + test('A state can not transition if it is not the current state', () { + expect( + () => LogicStateMachine([ + LogicStateFactory(() => StateXToAThenB(), [ + LogicStateTransition(), + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), []), + LogicStateFactory(() => StateA(), []), + ]).current, + throwsA(isA()), + ); + }); + + test('LogicStateNotCurrent.toString', () { + late final LogicStateNotCurrent tError; + try { + LogicStateMachine([ + LogicStateFactory(() => StateXToAThenB(), [ + LogicStateTransition(), + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), []), + LogicStateFactory(() => StateA(), []), + ]).current; + } catch (e) { + tError = e as LogicStateNotCurrent; + } + + expect( + tError.toString(), + equals( + 'LogicStateNotCurrent: ${tError.current.toString()} is not current.'), + ); + }); + + testWidgets( + 'A state can asynchronously transition itself to itself', + (tester) async { + final tMachine = LogicStateMachine( + [ + LogicStateFactory(() => StateZToZAsync(), [ + LogicStateTransition(), + LogicStateTransition(), + ]), + LogicStateFactory( + () => StateInstantComplete(), []), + ], + 0, + ); + + await tester.pump(const Duration(milliseconds: 5)); + + expect( + tMachine.complete, + isTrue, + ); + }, + timeout: const Timeout(Duration(milliseconds: 10)), + ); + + test('A machine notifies on completion', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), []), + ]); + + bool cComplete = false; + tMachine.addListener(() { + cComplete = tMachine.complete; + }); + tMachine.current.complete(); + + expect( + cComplete, + isTrue, + ); + }); + + test('complete. not current state', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), []), + ]); + + tMachine.current.onComplete.ignore(); + final tState = tMachine.current; + tMachine.current.complete(); + + expect( + () => tState.complete(), + throwsA(isA()), + ); + }); + + test('Transitions to the state of Type.', () { + final tMachine = LogicStateMachine( + [ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), []), + ], + ); + + tMachine.current.toByType(StateB); + + expect( + tMachine.current, + isInstanceOf(), + ); + }); + + test('Transitions to the state of Type. not current state.', () { + final tMachine = LogicStateMachine( + [ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), []), + ], + ); + + final tState = tMachine.current; + tMachine.current.toByType(StateB); + + expect( + () => tState.toByType(StateA), + throwsA(isA()), + ); + }); + + test('Transitions to the state of Type. Transition not found.', () { + final tMachine = LogicStateMachine( + [ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), []), + ], + ); + tMachine.current.onComplete.ignore(); + tMachine.current.toByType(StateC); + + expect( + tMachine.error?.error, + isInstanceOf(), + ); + }); + + test('Transitions to the state of Type. Factory not found.', () { + final tMachine = LogicStateMachine( + [ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), []), + ], + ); + tMachine.current.onComplete.ignore(); + tMachine.current.toByType(StateC); + + expect( + tMachine.error?.error, + isInstanceOf(), + ); + }); + + test('Transitions to the state of Type. not allowed.', () { + final tMachine = LogicStateMachine( + [ + LogicStateFactory(() => StateA(), [ + LogicStateTransition( + (LogicState current, LogicState next) => false), + ]), + LogicStateFactory(() => StateB(), []), + ], + ); + tMachine.current.onComplete.ignore(); + tMachine.current.toByType(StateB); + + expect( + tMachine.error?.error, + isInstanceOf(), + ); + + final tError = tMachine.error?.error as LogicStateTransitionNotAllowed; + expect( + tError.toString(), + equals( + 'LogicStateTransitionNotAllowed: ${tError.current.toString()} is not allowed to transition to ${tError.next.toString()}.'), + ); + }); + + test('A machine notifies on failed completion', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), []), + ]); + + Object? cError; + tMachine.addListener(() { + cError = tMachine.error?.error; + }); + tMachine.current.onComplete.ignore(); + tMachine.current.completeError('FakeError'); + + expect( + cError, + equals('FakeError'), + ); + }); + + test('completeError. not current state', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), []), + ]); + + tMachine.current.onComplete.ignore(); + final tState = tMachine.current; + tMachine.current.completeError('FakeError'); + + expect( + () => tState.completeError('FakeError'), + throwsA(isA()), + ); + }); + + test('A state can transition to back state if allowed.', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA()..backAllowed = true, [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateBackA(), []), + ]); + tMachine.current.complete(); + tMachine.current.complete(); + + expect( + tMachine.current, + isInstanceOf(), + ); + }); + + test('A state can not transition to back state', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateBackA(), []), + ]); + tMachine.current.complete(); + tMachine.current.complete(); + + expect( + tMachine.error?.error, + isInstanceOf(), + ); + }); + + test('backByType. not current state.', () { + final tMachine = LogicStateMachine([ + LogicStateFactory(() => StateA(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateB(), [ + LogicStateTransition(), + ]), + LogicStateFactory(() => StateA(), []), + ]); + tMachine.current.complete(); + final tState = tMachine.current; + tMachine.current.complete(); + + expect( + () => tState.backByType(StateA), + throwsA(isA()), + ); + }); + + test('LogicStateTransitionNotFound test', () { + // If const is added, test coverage will not be 100%. + // ignore: prefer_const_constructors + final tError = LogicStateTransitionNotFound(); + + expect( + tError.code, + equals(PatapataCoreExceptionCode.PPE101.name), + ); + }); +} + +class StateA extends LogicState {} + +class StateB extends LogicState {} + +class StateC extends LogicState {} + +class StateXToA extends LogicState { + @override + void init(Object? data) { + super.init(data); + to(); + } +} + +class StateXToAThenB extends LogicState { + @override + void init(Object? data) { + super.init(data); + to(); + to(); + } +} + +class StateZToZAsync extends LogicState { + @override + void init(Object? data) async { + super.init(data); + final tCounter = data as int; + + if (tCounter == 3) { + complete(); + } else { + await Future.delayed(const Duration(milliseconds: 1)); + to(tCounter + 1); + } + } +} + +class StateInstantComplete extends LogicState { + @override + void init(Object? data) { + super.init(data); + complete(); + } +} + +class StateBackA extends LogicState { + @override + void init(Object? data) { + super.init(data); + onComplete.ignore(); + backByType(StateA); + } +} diff --git a/packages/patapata_core/test/native_local_config_test.dart b/packages/patapata_core/test/native_local_config_test.dart new file mode 100644 index 0000000..ca5ab50 --- /dev/null +++ b/packages/patapata_core/test/native_local_config_test.dart @@ -0,0 +1,162 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/src/native_local_config_finder.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + testSetMockMethodCallHandler = TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger.setMockMethodCallHandler; + + const kBoolValueKey = 'kBoolValueKey'; + const kDoubleValueKey = 'kDoubleValueKey'; + const kIntValueKey = 'kIntValueKey'; + const kStringValueKey = 'kStringValueKey'; + + test('check bool value', () async { + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + expect( + tNativeLocalConfig.getBool(kBoolValueKey), Config.defaultValueForBool); + + await tNativeLocalConfig.setDefaults({kBoolValueKey: true}); + expect(tNativeLocalConfig.getBool(kBoolValueKey), true); + + await tNativeLocalConfig.setBool(kBoolValueKey, false); + expect(tNativeLocalConfig.getBool(kBoolValueKey), false); + + await tNativeLocalConfig.reset(kBoolValueKey); + expect(tNativeLocalConfig.getBool(kBoolValueKey), true); + }); + + test('check double value', () async { + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + expect(tNativeLocalConfig.getDouble(kDoubleValueKey), + Config.defaultValueForDouble); + + await tNativeLocalConfig.setDefaults({kDoubleValueKey: 1.0}); + expect(tNativeLocalConfig.getDouble(kDoubleValueKey), 1.0); + + await tNativeLocalConfig.setDouble(kDoubleValueKey, 2.0); + expect(tNativeLocalConfig.getDouble(kDoubleValueKey), 2.0); + + await tNativeLocalConfig.reset(kDoubleValueKey); + expect(tNativeLocalConfig.getDouble(kDoubleValueKey), 1.0); + }); + + test('check int value', () async { + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + expect(tNativeLocalConfig.getInt(kIntValueKey), Config.defaultValueForInt); + + await tNativeLocalConfig.setDefaults({kIntValueKey: 1}); + expect(tNativeLocalConfig.getInt(kIntValueKey), 1); + + await tNativeLocalConfig.setInt(kIntValueKey, 2); + expect(tNativeLocalConfig.getInt(kIntValueKey), 2); + + await tNativeLocalConfig.reset(kIntValueKey); + expect(tNativeLocalConfig.getInt(kIntValueKey), 1); + }); + + test('check String value', () async { + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + expect(tNativeLocalConfig.getString(kStringValueKey), + Config.defaultValueForString); + + await tNativeLocalConfig.setDefaults({kStringValueKey: '1'}); + expect(tNativeLocalConfig.getString(kStringValueKey), '1'); + + await tNativeLocalConfig.setString(kStringValueKey, '2'); + expect(tNativeLocalConfig.getString(kStringValueKey), '2'); + + await tNativeLocalConfig.reset(kStringValueKey); + expect(tNativeLocalConfig.getString(kStringValueKey), '1'); + }); + + test('resetAll', () async { + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + await tNativeLocalConfig.setBool(kBoolValueKey, false); + await tNativeLocalConfig.setDouble(kDoubleValueKey, 2.0); + await tNativeLocalConfig.setInt(kIntValueKey, 2); + await tNativeLocalConfig.setString(kStringValueKey, '2'); + + expect(tNativeLocalConfig.getBool(kBoolValueKey), false); + expect(tNativeLocalConfig.getDouble(kDoubleValueKey), 2.0); + expect(tNativeLocalConfig.getInt(kIntValueKey), 2); + expect(tNativeLocalConfig.getString(kStringValueKey), '2'); + + await tNativeLocalConfig.resetAll(); + + expect( + tNativeLocalConfig.getBool(kBoolValueKey), Config.defaultValueForBool); + expect(tNativeLocalConfig.getDouble(kDoubleValueKey), + Config.defaultValueForDouble); + expect(tNativeLocalConfig.getInt(kIntValueKey), Config.defaultValueForInt); + expect(tNativeLocalConfig.getString(kStringValueKey), + Config.defaultValueForString); + }); + + test('resetMany', () async { + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + const kIsSetKey1 = 'isSet1'; + const kIsSetKey2 = 'isSet2'; + const kIsNotSetKey1 = 'isNotSet1'; + const kIsNotSetKey2 = 'isNotSet2'; + + await tNativeLocalConfig.setDefaults({ + kIsSetKey1: 1, + kIsSetKey2: 1, + }); + + await tNativeLocalConfig.setInt(kIsSetKey1, 2); + await tNativeLocalConfig.setInt(kIsNotSetKey1, 2); + + await tNativeLocalConfig.resetMany([ + kIsSetKey1, + kIsSetKey2, + kIsNotSetKey1, + kIsNotSetKey2, + ]); + + expect(tNativeLocalConfig.getInt(kIsSetKey1), 1); + expect(tNativeLocalConfig.getInt(kIsSetKey2), 1); + expect(tNativeLocalConfig.getInt(kIsNotSetKey1), Config.defaultValueForInt); + expect(tNativeLocalConfig.getInt(kIsNotSetKey2), Config.defaultValueForInt); + }); + + test('setMany', () async { + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + final Map tObjects = { + 'name': 'GREE, Inc.', + }; + + await tNativeLocalConfig.setMany(tObjects); + + expect(tNativeLocalConfig.getString('name'), 'GREE, Inc.'); + }); + + test('sendError', () async { + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + tNativeLocalConfig.sendError(); + }); +} diff --git a/packages/patapata_core/test/network_test.dart b/packages/patapata_core/test/network_test.dart new file mode 100644 index 0000000..07b6016 --- /dev/null +++ b/packages/patapata_core/test/network_test.dart @@ -0,0 +1,227 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +void main() { + testInitialize(); + + group('NetworkInformation', () { + test('factory unknown', () async { + final tNetworkInformation = NetworkInformation.unknown(); + + expect( + tNetworkInformation.connectivity == NetworkConnectivity.unknown, + isTrue, + ); + }); + + test('toString', () async { + final tNetworkInformation = NetworkInformation.unknown(); + + expect( + tNetworkInformation.toString().isNotEmpty, + isTrue, + ); + }); + + test('copyWith', () async { + NetworkInformation tNetworkInformation = NetworkInformation.unknown(); + tNetworkInformation = tNetworkInformation.copyWith(); + + expect( + tNetworkInformation.connectivity == NetworkConnectivity.unknown, + isTrue, + ); + }); + + test('Operator equals', () async { + NetworkInformation tNetworkInformationMobile1 = const NetworkInformation( + connectivity: NetworkConnectivity.mobile, + ); + + NetworkInformation tNetworkInformationMobile2 = const NetworkInformation( + connectivity: NetworkConnectivity.mobile, + ); + + NetworkInformation tNetworkInformationWifi = const NetworkInformation( + connectivity: NetworkConnectivity.wifi, + ); + + expect( + tNetworkInformationMobile1 == tNetworkInformationMobile2, + isTrue, + ); + + expect( + tNetworkInformationMobile1 == tNetworkInformationWifi, + isFalse, + ); + }); + + test('getter hashCode', () async { + expect( + NetworkInformation.unknown().hashCode != 0, + isTrue, + ); + }); + }); + + group('class NetworkPlugin', () { + test('getter information', () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + + expect( + tNetwork.information == NetworkInformation.unknown(), + isTrue, + ); + }); + + test('Function init', () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + final App tApp = createApp(); + + final bool tResult = await tNetwork.init(tApp); + expect( + tResult, + isTrue, + ); + + tNetwork.dispose(); + }); + + test('getter informationStream', () async { + final App tApp = createApp(); + final tNetwork = NetworkPlugin(); + final tValues = [ + NetworkConnectivity.none, + NetworkConnectivity.other, + NetworkConnectivity.mobile, + NetworkConnectivity.wifi, + NetworkConnectivity.ethernet, + NetworkConnectivity.bluetooth, + NetworkConnectivity.vpn, + ]; + + tNetwork.init(tApp); + + final tFuture = expectLater( + tNetwork.informationStream.asyncMap((event) => event.connectivity), + emitsInOrder(tValues), + ); + + for (var i in tValues) { + await tNetwork.testChangeConnectivity(i); + } + + await tFuture; + + tNetwork.dispose(); + }); + + test('Function _onConnectivityChanged NetworkConnectivity.none', () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + + testOnConnectivityChangedValue = NetworkConnectivity.none; + await tNetwork.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect( + tNetwork.information.connectivity == NetworkConnectivity.none, + isTrue, + ); + }); + + test('Function _onConnectivityChanged NetworkConnectivity.other', () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + + testOnConnectivityChangedValue = NetworkConnectivity.other; + await tNetwork.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect( + tNetwork.information.connectivity == NetworkConnectivity.other, + isTrue, + ); + }); + + test('Function _onConnectivityChanged NetworkConnectivity.mobile', + () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + + testOnConnectivityChangedValue = NetworkConnectivity.mobile; + await tNetwork.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect( + tNetwork.information.connectivity == NetworkConnectivity.mobile, + isTrue, + ); + }); + + test('Function _onConnectivityChanged NetworkConnectivity.wifi', () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + + testOnConnectivityChangedValue = NetworkConnectivity.wifi; + await tNetwork.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect( + tNetwork.information.connectivity == NetworkConnectivity.wifi, + isTrue, + ); + }); + + test('Function _onConnectivityChanged NetworkConnectivity.ethernet', + () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + + testOnConnectivityChangedValue = NetworkConnectivity.ethernet; + await tNetwork.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect( + tNetwork.information.connectivity == NetworkConnectivity.ethernet, + isTrue, + ); + }); + + test('Function _onConnectivityChanged NetworkConnectivity.bluetooth', + () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + + testOnConnectivityChangedValue = NetworkConnectivity.bluetooth; + await tNetwork.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect( + tNetwork.information.connectivity == NetworkConnectivity.bluetooth, + isTrue, + ); + }); + + test('Function _onConnectivityChanged NetworkConnectivity.vpn', () async { + final NetworkPlugin tNetwork = NetworkPlugin(); + + testOnConnectivityChangedValue = NetworkConnectivity.vpn; + await tNetwork.didChangeAppLifecycleState(AppLifecycleState.resumed); + + expect( + tNetwork.information.connectivity == NetworkConnectivity.vpn, + isTrue, + ); + }); + + test('Function dispose', () async { + final App tApp = createApp(); + final NetworkPlugin tNetwork = NetworkPlugin(); + await tNetwork.init(tApp); + await tNetwork.dispose(); + + expect( + tNetwork.disposed, + isTrue, + ); + }); + }); +} diff --git a/packages/patapata_core/test/notifications_test.dart b/packages/patapata_core/test/notifications_test.dart new file mode 100644 index 0000000..27e716b --- /dev/null +++ b/packages/patapata_core/test/notifications_test.dart @@ -0,0 +1,156 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/src/exception.dart'; + +import 'utils/patapata_core_test_utils.dart'; +import 'pages/notification_page.dart'; + +const NotificationResponse notificationResponse = NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: "notification/", +); + +class _EmptyEnvironment {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('check init success', () async { + final NotificationsPlugin tNotificationsPlugin = NotificationsPlugin(); + + final App tApp = createApp(environment: _EmptyEnvironment()); + final bool tResult = await tNotificationsPlugin.init(tApp); + + expect( + tResult, + isTrue, + ); + }); + + test('check init success using environment', () async { + final NotificationsPlugin tNotificationsPlugin = NotificationsPlugin(); + + final App tApp = createApp(); + + final bool tResult = await tNotificationsPlugin.init(tApp); + + expect( + tResult, + isTrue, + ); + }); + + test( + 'check notifications', + () async { + final NotificationsPlugin tNotificationsPlugin = NotificationsPlugin(); + + expectLater( + tNotificationsPlugin.notifications.asyncMap((event) => event.payload), + emitsInOrder( + [ + "notification/", + ], + ), + ); + + final App tApp = createApp(); + + await tNotificationsPlugin.init(tApp); + + tNotificationsPlugin.enableStandardAppIntegration(); + + tNotificationsPlugin.mockRecieveNotificationResponse( + notificationResponse, + ); + }, + ); + + test( + 'check locationFromPayload', + () async { + final NotificationsPlugin tNotificationsPlugin = NotificationsPlugin(); + final App tApp = createApp(); + await tNotificationsPlugin.init(tApp); + + expect( + tNotificationsPlugin.uriFromPayload( + null, + ), + isNull, + ); + + expect( + tNotificationsPlugin.uriFromPayload( + "notification/", + ), + Uri.parse("notification/"), + ); + + expect( + tNotificationsPlugin.uriFromPayload( + '{"location": "notification/"}', + ), + Uri.parse("notification/"), + ); + }, + ); + + testWidgets( + 'check getInitialRouteData', + (WidgetTester tester) async { + notificationAppLaunchDetailsMap = { + "notificationLaunchedApp": true, + 'notificationResponse': { + 'notificationResponseType': 0, + 'payload': "notification/", + } + }; + + final App tApp = createApp(); + tApp.run(); + + await tApp.runProcess(() async { + final tNotificationsPlugin = tApp.getPlugin(); + + tApp.getPlugin()?.enableStandardAppIntegration(); + + await tester.pumpAndSettle(); + + final tStandardRouteData = + await tNotificationsPlugin?.getInitialRouteData(); + + expect( + tStandardRouteData?.factory?.pageType, + NotificationPage, + ); + }); + }, + ); + + test('check dispose', () async { + final NotificationsPlugin tNotificationsPlugin = NotificationsPlugin(); + + tNotificationsPlugin.dispose(); + + expect( + tNotificationsPlugin.disposed, + isTrue, + ); + }); + + test('NotificationsInitializationException test', () async { + // ignore: prefer_const_constructors + final tException = NotificationsInitializationException(); + + expect( + tException.code, + equals(PatapataCoreExceptionCode.PPE501.name), + ); + }); +} diff --git a/packages/patapata_core/test/package_info_test.dart b/packages/patapata_core/test/package_info_test.dart new file mode 100644 index 0000000..2075a29 --- /dev/null +++ b/packages/patapata_core/test/package_info_test.dart @@ -0,0 +1,39 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('PackageInfoPlugin', () async { + PackageInfoPlugin.setMockValues( + appName: 'mock_patapata_core', + packageName: 'io.flutter.plugins.mockpatapatacore', + version: '1.0', + buildNumber: '1', + buildSignature: 'patapata_core_build_signature', + installerStore: null, + ); + + final PackageInfoPlugin tPackageInfo = PackageInfoPlugin(); + + final tApp = createApp(); + final bool tResult = await tPackageInfo.init(tApp); + + expect( + tResult, + isTrue, + ); + + expect( + tPackageInfo.info.appName == 'mock_patapata_core', + isTrue, + ); + }); +} diff --git a/packages/patapata_core/test/pages/error_page.dart b/packages/patapata_core/test/pages/error_page.dart new file mode 100644 index 0000000..6bf9041 --- /dev/null +++ b/packages/patapata_core/test/pages/error_page.dart @@ -0,0 +1,23 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +class ErrorPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + final tError = pageData.error as PatapataException; + return Scaffold( + appBar: AppBar( + title: Text( + tError.localizedMessage, + ), + ), + body: const SizedBox.shrink(), + ); + } +} diff --git a/packages/patapata_core/test/pages/home_page.dart b/packages/patapata_core/test/pages/home_page.dart new file mode 100644 index 0000000..92695e9 --- /dev/null +++ b/packages/patapata_core/test/pages/home_page.dart @@ -0,0 +1,22 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +class HomePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, "home.title"), + ), + ), + body: const SizedBox.shrink(), + ); + } +} diff --git a/packages/patapata_core/test/pages/notification_page.dart b/packages/patapata_core/test/pages/notification_page.dart new file mode 100644 index 0000000..899b356 --- /dev/null +++ b/packages/patapata_core/test/pages/notification_page.dart @@ -0,0 +1,21 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +class NotificationPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, "notification.title"), + ), + ), + body: const SizedBox.shrink(), + ); + } +} diff --git a/packages/patapata_core/test/pages/startup_page.dart b/packages/patapata_core/test/pages/startup_page.dart new file mode 100644 index 0000000..73e74ad --- /dev/null +++ b/packages/patapata_core/test/pages/startup_page.dart @@ -0,0 +1,232 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +class SplashPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'SplashPage', + ), + ), + body: const SizedBox.shrink(), + ); + } +} + +class StartupPageA extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'StartupPageA', + ), + ), + body: ListView( + children: [ + TextButton( + onPressed: () { + pageData(true); + }, + child: const Text('Complete'), + ), + TextButton( + onPressed: () { + getApp().startupSequence?.resetMachine(); + }, + child: const Text('Reset'), + ), + TextButton( + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => StartupModalPageA(completer: pageData), + )); + }, + child: const Text('PushModalA'), + ), + ], + ), + ); + } +} + +class StartupPageB extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'StartupPageB', + ), + ), + body: ListView( + children: [ + TextButton( + onPressed: () { + pageData(true); + }, + child: const Text('Complete'), + ), + TextButton( + onPressed: () { + getApp().startupSequence?.resetMachine(); + }, + child: const Text('Reset'), + ), + ], + ), + ); + } +} + +class StartupModalPageA extends StatelessWidget { + const StartupModalPageA({ + super.key, + this.completer, + }); + + final StartupPageCompleter? completer; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'StartupModalPageA', + ), + ), + body: ListView( + children: [ + TextButton( + onPressed: () { + completer?.call(true); + }, + child: const Text('Complete'), + ), + TextButton( + onPressed: () { + completer?.call(true); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const StartupModalPageB(), + )); + }, + child: const Text('CompleteAndPushModaB'), + ), + TextButton( + onPressed: () { + Navigator.of(context).replace( + oldRoute: ModalRoute.of(context)!, + newRoute: MaterialPageRoute( + builder: (context) => StartupModalPageB( + completer: completer, + ), + ), + ); + }, + child: const Text('ReplaceAtoB'), + ), + TextButton( + onPressed: () { + Navigator.of(context).removeRoute(ModalRoute.of(context)!); + }, + child: const Text('Remove'), + ), + TextButton( + onPressed: () { + getApp().startupSequence?.resetMachine(); + }, + child: const Text('Reset'), + ), + ], + ), + ); + } +} + +class StartupModalPageB extends StatelessWidget { + const StartupModalPageB({ + super.key, + this.completer, + }); + + final StartupPageCompleter? completer; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'StartupModalPageB', + ), + ), + body: ListView( + children: [ + TextButton( + onPressed: () { + completer?.call(true); + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const StartupModalPageC(), + )); + }, + child: const Text('CompleteAndPushModaC'), + ), + TextButton( + onPressed: () { + Navigator.of(context).removeRoute(ModalRoute.of(context)!); + }, + child: const Text('Remove'), + ), + ], + ), + ); + } +} + +class StartupModalPageC extends StatelessWidget { + const StartupModalPageC({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'StartupModalPageC', + ), + ), + body: ListView( + children: [ + TextButton( + onPressed: () { + Navigator.of(context).removeRoute(ModalRoute.of(context)!); + }, + child: const Text('Remove'), + ), + ], + ), + ); + } +} + +class TestHomePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'HomePage', + ), + ), + body: const SizedBox.shrink(), + ); + } +} diff --git a/packages/patapata_core/test/permission/permissions_for_android_test.dart b/packages/patapata_core/test/permission/permissions_for_android_test.dart new file mode 100644 index 0000000..e2505a5 --- /dev/null +++ b/packages/patapata_core/test/permission/permissions_for_android_test.dart @@ -0,0 +1,115 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import '../utils/patapata_core_test_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + + test('trackingRequested for Android', () async { + late App app; + + app = createApp(); + + expect( + app.permissions.trackingRequested, + true, + ); + + app.dispose(); + }); + + test('notificationsRequested for Android', () async { + late App app; + + app = createApp(); + app.permissions.testSetRequested( + notificationsRequested: true, + ); + + expect( + app.permissions.notificationsRequested, + true, + ); + + app.permissions.testSetRequested( + notificationsRequested: false, + ); + + expect( + app.permissions.notificationsRequested, + false, + ); + + app.dispose(); + }); + + test('requestTracking for Android (notSupported)', () async { + late App app; + + app = createApp(); + + final tResult = await app.permissions.requestTracking(); + + expect(tResult, PermissionTrackingResult.notSupported); + }); + + test('requestNotifications for Android (true)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetLocalNotificationsPermission(true); + final tResult = await app.permissions.requestNotifications(); + + expect(tResult, PermissionNotificationResult.authorized); + + app.dispose(); + }); + + test('requestNotifications for Android (false)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetLocalNotificationsPermission(false); + final tResult = await app.permissions.requestNotifications(); + + expect(tResult, PermissionNotificationResult.denied); + + app.dispose(); + }); + + test('notificationsStream for Android', () async { + late App app; + + app = createApp(); + + final tStreamResultValues = [ + PermissionNotificationResult.authorized, + PermissionNotificationResult.denied, + ]; + + final tFuture = expectLater( + app.permissions.notificationsStream.asyncMap((event) => event), + emitsInOrder(tStreamResultValues), + ); + + app.permissions.testSetLocalNotificationsPermission(true); + await app.permissions.requestNotifications(); + + app.permissions.testSetLocalNotificationsPermission(false); + await app.permissions.requestNotifications(); + + await tFuture; + + app.dispose(); + }); +} diff --git a/packages/patapata_core/test/permission/permissions_for_ios_test.dart b/packages/patapata_core/test/permission/permissions_for_ios_test.dart new file mode 100644 index 0000000..dece75d --- /dev/null +++ b/packages/patapata_core/test/permission/permissions_for_ios_test.dart @@ -0,0 +1,216 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import '../utils/patapata_core_test_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + test('trackingRequested for iOS', () async { + late App app; + + app = createApp(); + app.permissions.testSetRequested( + trackingRequested: true, + ); + + expect( + app.permissions.trackingRequested, + true, + ); + + app.permissions.testSetRequested(trackingRequested: false); + + expect( + app.permissions.trackingRequested, + false, + ); + + app.dispose(); + }); + + test('notificationsRequested for iOS', () async { + late App app; + + app = createApp(); + app.permissions.testSetRequested( + notificationsRequested: true, + ); + + expect( + app.permissions.notificationsRequested, + true, + ); + + app.permissions.testSetRequested(notificationsRequested: false); + + expect( + app.permissions.notificationsRequested, + false, + ); + + app.dispose(); + }); + + test('requestTracking for iOS (notSupported)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetPermissionTrackingResult( + PermissionTrackingResult.notSupported, + ); + + final tResult = await app.permissions.requestTracking(); + + expect(tResult, PermissionTrackingResult.notSupported); + + app.dispose(); + }); + + test('requestTracking for iOS (authorized)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetPermissionTrackingResult( + PermissionTrackingResult.authorized, + ); + + final tResult = await app.permissions.requestTracking(); + + expect(tResult, PermissionTrackingResult.authorized); + + app.dispose(); + }); + + test('requestTracking for iOS (denied)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetPermissionTrackingResult( + PermissionTrackingResult.denied, + ); + + final tResult = await app.permissions.requestTracking(); + + expect(tResult, PermissionTrackingResult.denied); + + app.dispose(); + }); + + test('requestTracking for iOS (notDetermined)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetPermissionTrackingResult( + PermissionTrackingResult.notDetermined, + ); + + final tResult = await app.permissions.requestTracking(); + + expect(tResult, PermissionTrackingResult.notDetermined); + + app.dispose(); + }); + + test('requestTracking for iOS (restricted)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetPermissionTrackingResult( + PermissionTrackingResult.restricted, + ); + + final tResult = await app.permissions.requestTracking(); + + expect(tResult, PermissionTrackingResult.restricted); + + app.dispose(); + }); + + test('trackingStream for iOS', () async { + late App app; + + app = createApp(); + + final tFuture = expectLater( + app.permissions.trackingStream.asyncMap((event) => event), + emitsInOrder(PermissionTrackingResult.values), + ); + + for (PermissionTrackingResult tResult in PermissionTrackingResult.values) { + app.permissions.testSetPermissionTrackingResult( + tResult, + ); + + await app.permissions.requestTracking(); + } + + await tFuture; + + app.dispose(); + }); + + test('requestNotifications for iOS (true)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetLocalNotificationsPermission(true); + final tResult = await app.permissions.requestNotifications(); + + expect(tResult, PermissionNotificationResult.authorized); + + app.dispose(); + }); + + test('requestNotifications for iOS (false)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetLocalNotificationsPermission(false); + final tResult = await app.permissions.requestNotifications(); + + expect(tResult, PermissionNotificationResult.denied); + + app.dispose(); + }); + + test('notificationsStream for iOS', () async { + late App app; + + app = createApp(); + + final tStreamResultValues = [ + PermissionNotificationResult.authorized, + PermissionNotificationResult.denied, + ]; + + final tFuture = expectLater( + app.permissions.notificationsStream.asyncMap((event) => event), + emitsInOrder(tStreamResultValues), + ); + + app.permissions.testSetLocalNotificationsPermission(true); + await app.permissions.requestNotifications(); + + app.permissions.testSetLocalNotificationsPermission(false); + await app.permissions.requestNotifications(); + + await tFuture; + + app.dispose(); + }); +} diff --git a/packages/patapata_core/test/permission/permissions_for_macos_test.dart b/packages/patapata_core/test/permission/permissions_for_macos_test.dart new file mode 100644 index 0000000..7c4af1d --- /dev/null +++ b/packages/patapata_core/test/permission/permissions_for_macos_test.dart @@ -0,0 +1,103 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import '../utils/patapata_core_test_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + + test('trackingRequested for macOS', () async { + late App app; + + app = createApp(); + + expect( + app.permissions.trackingRequested, + true, + ); + + app.dispose(); + }); + + test('notificationsRequested for macOS', () async { + late App app; + + app = createApp(); + app.permissions.testSetRequested(notificationsRequested: true); + + expect( + app.permissions.notificationsRequested, + true, + ); + + app.permissions.testSetRequested( + notificationsRequested: false, + ); + + expect( + app.permissions.notificationsRequested, + false, + ); + + app.dispose(); + }); + + test('requestNotifications for macOS (true)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetLocalNotificationsPermission(true); + final tResult = await app.permissions.requestNotifications(); + + expect(tResult, PermissionNotificationResult.authorized); + + app.dispose(); + }); + + test('requestNotifications for macOS (false)', () async { + late App app; + + app = createApp(); + + app.permissions.testSetLocalNotificationsPermission(false); + final tResult = await app.permissions.requestNotifications(); + + expect(tResult, PermissionNotificationResult.denied); + + app.dispose(); + }); + + test('notificationsStream for macOS', () async { + late App app; + + app = createApp(); + + final tStreamResultValues = [ + PermissionNotificationResult.authorized, + PermissionNotificationResult.denied, + ]; + + final tFuture = expectLater( + app.permissions.notificationsStream.asyncMap((event) => event), + emitsInOrder(tStreamResultValues), + ); + + app.permissions.testSetLocalNotificationsPermission(true); + await app.permissions.requestNotifications(); + + app.permissions.testSetLocalNotificationsPermission(false); + await app.permissions.requestNotifications(); + + await tFuture; + + app.dispose(); + }); +} diff --git a/packages/patapata_core/test/platform_dialog_test.dart b/packages/patapata_core/test/platform_dialog_test.dart new file mode 100644 index 0000000..7e18b05 --- /dev/null +++ b/packages/patapata_core/test/platform_dialog_test.dart @@ -0,0 +1,222 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:patapata_core/src/widgets/platform_dialog.dart'; + +typedef DialogActionBuilder = PlatformDialogAction Function(dynamic result); + +abstract class TestButtonBase { + TestButtonBase({ + required this.expectedResults, + }); + + final T expectedResults; + dynamic actuallyResults; + + Finder createFinder(TargetPlatform? target); + PlatformDialogAction createAction(); + + void defaultAction() { + actuallyResults = expectedResults; + } + + T defaultReturn() { + return expectedResults; + } + + Widget? getTopWidget(Widget widget, TargetPlatform? target) { + if (target == TargetPlatform.iOS || target == TargetPlatform.macOS) { + return widget is CupertinoDialogAction ? widget.child : null; + } + + return widget is TextButton ? widget.child : null; + } +} + +class TestButton1 extends TestButtonBase { + TestButton1({ + required super.expectedResults, + }); + + @override + Finder createFinder(TargetPlatform? target) { + return find.byWidgetPredicate((widget) { + Widget? tChild = getTopWidget(widget, target); + if (tChild != null && tChild is Text) { + if (tChild.data == 'text button') { + return true; + } + } + + return false; + }); + } + + @override + PlatformDialogAction createAction() { + return PlatformDialogAction( + text: 'text button', + action: defaultAction, + result: defaultReturn, + ); + } +} + +class TestButton2 extends TestButtonBase { + TestButton2({ + required super.expectedResults, + }); + + @override + Finder createFinder(TargetPlatform? target) { + return find.byWidgetPredicate((widget) { + Widget? tChild = getTopWidget(widget, target); + if (tChild != null && tChild is SizedBox) { + if (tChild.width == 20 && tChild.height == 20) { + tChild = tChild.child; + if (tChild != null && tChild is ColoredBox) { + if (tChild.color == Colors.red) { + return true; + } + } + } + } + + return false; + }); + } + + @override + PlatformDialogAction createAction() { + return PlatformDialogAction( + child: const SizedBox( + width: 20, + height: 20, + child: ColoredBox( + color: Colors.red, + ), + ), + action: defaultAction, + result: defaultReturn, + ); + } +} + +const _localizationsDelegates = [ + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +]; + +class TestApp { + TestApp({ + required this.targetPlatform, + }); + + final TargetPlatform? targetPlatform; + final appKey = GlobalKey(); + + Widget createApp() { + final tHome = KeyedSubtree( + key: appKey, + child: const SizedBox.shrink(), + ); + + if (targetPlatform == TargetPlatform.iOS || + targetPlatform == TargetPlatform.macOS) { + return CupertinoApp( + home: tHome, + localizationsDelegates: _localizationsDelegates, + ); + } + + return MaterialApp( + home: tHome, + ); + } + + Type? getDialogType() { + if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + return CupertinoAlertDialog; + } + + return AlertDialog; + } +} + +void main() { + final List tTestApps = kIsWeb + ? [ + TestApp(targetPlatform: null), + ] + : [ + TestApp(targetPlatform: TargetPlatform.android), + TestApp(targetPlatform: TargetPlatform.windows), + TestApp(targetPlatform: TargetPlatform.iOS), + TestApp(targetPlatform: TargetPlatform.macOS), + ]; + + final tTestButtons = [ + TestButton1(expectedResults: true), + TestButton2(expectedResults: Random().nextInt(1000)), + ]; + + for (final testApp in tTestApps) { + final tTestMessage = + 'Test PlatformDialog for ${testApp.targetPlatform?.name ?? 'web'}.'; + + group('Guard $tTestMessage', () { + testWidgets(tTestMessage, (WidgetTester tester) async { + if (testApp.targetPlatform != null) { + debugDefaultTargetPlatformOverride = testApp.targetPlatform; + } + + await tester.pumpWidget(testApp.createApp()); + + for (final testButton in tTestButtons) { + Future? tTapTestFuture; + final tResultTestCompleter = Completer(); + + PlatformDialog.show( + context: testApp.appKey.currentContext!, + actions: tTestButtons.map((e) => e.createAction()).toList(), + message: tTestMessage, + ).then((value) async { + await tTapTestFuture; + expect(value, testButton.expectedResults); + expect(value, testButton.actuallyResults); + tResultTestCompleter.complete(); + }); + await tester.pumpAndSettle(); + + // Confirmation of the existence of the Widget. + expect(find.byType(testApp.getDialogType()!), findsOneWidget); + expect(find.text(tTestMessage), findsOneWidget); + for (final e in tTestButtons) { + expect(e.createFinder(testApp.targetPlatform), findsOneWidget); + } + + // Test the behavior when the button1 is pressed. + tTapTestFuture = + tester.tap(testButton.createFinder(testApp.targetPlatform)); + await Future.wait([tTapTestFuture, tResultTestCompleter.future]); + await tester.pumpAndSettle(); + } + + debugDefaultTargetPlatformOverride = null; + }); + }); + } +} diff --git a/packages/patapata_core/test/plugin_test.dart b/packages/patapata_core/test/plugin_test.dart new file mode 100644 index 0000000..ea1b9e6 --- /dev/null +++ b/packages/patapata_core/test/plugin_test.dart @@ -0,0 +1,229 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +// ignore_for_file: invalid_use_of_protected_member + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +void main() { + testInitialize(); + + group('Plugin', () { + void doDefaultTests(Plugin Function() createPlugin, String name) { + test('name should return the runtime type as a string', () { + final tPlugin = createPlugin(); + expect(tPlugin.name, equals(name)); + }); + + test('dependencies should return an empty list by default', () { + final tPlugin = createPlugin(); + expect(tPlugin.dependencies, isEmpty); + }); + + test('requireRemoteConfig should return false by default', () { + final tPlugin = createPlugin(); + expect(tPlugin.requireRemoteConfig, isFalse); + }); + + test('remoteConfigEnabledKey should return the correct value', () { + final tPlugin = createPlugin(); + expect(tPlugin.remoteConfigEnabledKey, + equals('patapata_plugin_${name}_enabled')); + }); + + test('app should return the initialized App instance', () async { + final tPlugin = createPlugin(); + final tApp = createApp(); + await tPlugin.init(tApp); + expect(tPlugin.app, equals(tApp)); + }); + + test('initialized should return true after initialization', () async { + final tPlugin = createPlugin(); + final tApp = createApp(); + await tPlugin.init(tApp); + expect(tPlugin.initialized, isTrue); + }); + + test('disposed should return true after disposal', () async { + final tPlugin = createPlugin(); + final tApp = createApp(); + await tPlugin.init(tApp); + await tPlugin.dispose(); + expect(tPlugin.disposed, isTrue); + }); + + test('createAppWidgetWrapper should return the child widget by default', + () { + final tPlugin = createPlugin(); + final tChild = Container(); + final wrapper = tPlugin.createAppWidgetWrapper(tChild); + expect(wrapper, equals(tChild)); + }); + + test('createRemoteConfig should return null by default', () { + final tPlugin = createPlugin(); + final remoteConfig = tPlugin.createRemoteConfig(); + expect(remoteConfig, isNull); + }); + + test('createLocalConfig should return null by default', () { + final tPlugin = createPlugin(); + final localConfig = tPlugin.createLocalConfig(); + expect(localConfig, isNull); + }); + + test('createRemoteMessaging should return null by default', () { + final tPlugin = createPlugin(); + final remoteMessaging = tPlugin.createRemoteMessaging(); + expect(remoteMessaging, isNull); + }); + + test('navigatorObservers should return an empty list by default', () { + final tPlugin = createPlugin(); + final observers = tPlugin.navigatorObservers; + expect(observers, isEmpty); + }); + } + + group('Extended defaults', + () => doDefaultTests(() => _BasePlugin(), '_BasePlugin')); + + group('Inline defaults', + () => doDefaultTests(() => Plugin.inline(), 'inline')); + + group('Extended others', () { + test('init can not be executed multiple times', () async { + final tPlugin = _BasePlugin(); + final tApp = createApp(); + await tPlugin.init(tApp); + + expect( + () async => await tPlugin.init(tApp), throwsA(isA())); + }); + + test('dispose can not be executed multiple times', () async { + final tPlugin = _BasePlugin(); + final tApp = createApp(); + await tPlugin.init(tApp); + await tPlugin.dispose(); + + expect(() async => await tPlugin.dispose(), throwsA(isA())); + }); + + testWidgets('native mock enable/disable calls should be called', + (tester) async { + final tPlugin = _BasePlugin(shouldCall: expectAsync0(() {}, count: 2)); + final tApp = createApp( + plugins: [tPlugin], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tApp.removePlugin(tPlugin); + }); + + tApp.dispose(); + }); + + test('An error should be thrown if dependencies are not satisfied', + () async { + final tPlugin = _DependencyPlugin(); + final tApp = createApp(); + + expect( + () async => await tPlugin.init(tApp), throwsA(isA())); + }); + }); + + group('Inline others', () { + testWidgets('You can make a complete custom inline plugin', + (tester) async { + final tWidgetKey = GlobalKey(); + final tPlugin = Plugin.inline( + name: 'test', + dependencies: [_BasePlugin], + requireRemoteConfig: true, + init: expectAsync1((App app) async => true, count: 1), + dispose: expectAsync0(() {}, count: 1), + createAppWidgetWrapper: (child) => KeyedSubtree( + key: tWidgetKey, + child: child, + ), + createRemoteConfig: () => MockRemoteConfig({ + 'r': 'r', + }), + createLocalConfig: () => MockLocalConfig({ + 'l': 'l', + }), + createRemoteMessaging: () => MockRemoteMessaging( + getToken: () => Future.value('token'), + ), + navigatorObservers: () => [NavigatorObserver()], + ); + + final tApp = createApp( + plugins: [ + _BasePlugin(), + tPlugin, + ], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(tPlugin.name, equals('test')); + expect(tPlugin.dependencies, equals([_BasePlugin])); + expect(tPlugin.requireRemoteConfig, isTrue); + expect(tPlugin.remoteConfigEnabledKey, + equals('patapata_plugin_test_enabled')); + expect(tPlugin.app, equals(tApp)); + expect(tPlugin.initialized, isTrue); + expect(tPlugin.disposed, isFalse); + expect(tPlugin.navigatorObservers, isNotEmpty); + + expect(find.byKey(tWidgetKey), findsOneWidget); + expect(tApp.remoteConfig.getString('r'), equals('r')); + expect(tApp.localConfig.getString('l'), equals('l')); + expect(tApp.remoteMessaging.getToken(), completion('token')); + }); + + tApp.dispose(); + }); + }); + }); +} + +class _BasePlugin extends Plugin { + final void Function()? shouldCall; + + _BasePlugin({ + this.shouldCall, + }); + + @override + void mockPatapataEnable() { + shouldCall?.call(); + } + + @override + void mockPatapataDisable() { + shouldCall?.call(); + } +} + +class _DependencyPlugin extends Plugin { + @override + final List dependencies = [_BasePlugin]; +} diff --git a/packages/patapata_core/test/provider_model_test.dart b/packages/patapata_core/test/provider_model_test.dart new file mode 100644 index 0000000..4600161 --- /dev/null +++ b/packages/patapata_core/test/provider_model_test.dart @@ -0,0 +1,723 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +// ignore_for_file: invalid_use_of_protected_member + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +void main() { + test('You can create a ProviderModel and access all of it\'s variables', () { + expect( + () => _A() + ..intVariable.unsafeValue + ..stringVariable.unsafeValue + ..mapVariable.unsafeValue, + returnsNormally, + ); + }); + + test('You can access correct data from a variable', () { + expect( + _A().intVariable.unsafeValue, + equals(_kIntValue), + ); + }); + + test('You can access correct data from a variable when a Map', () { + expect( + _A().mapVariable.unsafeValue, + equals(_kMapValue), + ); + }); + + test('You can get the type of a ProviderModelVariable', () { + final tA = _A(); + expect(tA.intVariable.type, equals(int)); + }); + + test('You can begin a batch', () { + final tA = _A(); + final tBatch = tA.begin(tA.keyA); + tBatch.commit(); + }); + + test('You can begin a batch when another batch is active with the same key', + () { + final tA = _A(); + tA.begin(tA.keyA); + expect(() => tA.begin(tA.keyA), returnsNormally); + }); + + test( + 'You can begin a batch when another batch is active with a different key', + () { + final tA = _A(); + tA.begin(tA.keyA); + expect(() => tA.begin(tA.keyB), returnsNormally); + }); + + test('You can begin a batch, and set values with it', () { + final tA = _A(); + final tBatch = tA.begin(tA.keyA); + tBatch.set(tA.intVariable, 2); + expect(tBatch.get(tA.intVariable), equals(2)); + expect(tA.intVariable.unsafeValue, equals(_kIntValue)); + tBatch.commit(); + expect(tA.intVariable.unsafeValue, equals(2)); + expect(tBatch.completed, isTrue); + expect(tBatch.valid, isTrue); + }); + + test('You can cancel a batch', () { + final tA = _A(); + final tBatch = tA.begin(tA.keyA); + tBatch.set(tA.intVariable, 2); + expect(tBatch.get(tA.intVariable), equals(2)); + expect(tA.intVariable.unsafeValue, equals(_kIntValue)); + tBatch.cancel(); + expect(tA.intVariable.unsafeValue, _kIntValue); + expect(tBatch.completed, isTrue); + expect(tBatch.valid, isFalse); + }); + + test('When you commit a batch, listeners get notified', () { + final tA = _A(); + + tA.addListener(expectAsync0(() {}, count: 1)); + + final tBatch = tA.begin(tA.keyA); + tBatch.set(tA.intVariable, 2); + tBatch.commit(); + }); + + test('You can cancel a batch, and noone gets notified', () { + final tA = _A(); + + tA.addListener(expectAsync0(() {}, count: 0)); + + final tBatch = tA.begin(tA.keyA); + tBatch.set(tA.intVariable, 2); + tBatch.cancel(); + }); + + test('When you commit a batch with no notify, listeners do not get notified', + () { + final tA = _A(); + + tA.addListener(expectAsync0(() {}, count: 0)); + + final tBatch = tA.begin(tA.keyA); + tBatch.set(tA.intVariable, 2); + tBatch.commit(notify: false); + }); + + test('You can begin multiple batches and the last comitted one wins', () { + final tA = _A(); + final tBA = tA.begin(tA.keyA); + final tBB = tA.begin(tA.keyB); + + tBA.set(tA.intVariable, 3); + tBB.set(tA.intVariable, 4); + + tBB.commit(); + tBA.commit(); + + expect(tA.intVariable.unsafeValue, equals(3)); + }); + + test('You can lock for a batch', () async { + final tA = _A(); + expect( + await tA.lock((batch) => batch.commit()), + equals(true), + ); + }); + + test( + 'You can lock for a batch and the result will be true when the batch is not commited', + () async { + final tA = _A(); + expect( + await tA.lock((batch) {}), + equals(true), + ); + }); + + test('You can lock for a batch and will return false on batch cancel', + () async { + final tA = _A(); + expect( + await tA.lock((batch) => batch.cancel()), + equals(false), + ); + }); + + test('You can lock for a batch, and set values with it', () async { + final tA = _A(); + + expect( + await tA.lock((batch) { + batch.set(tA.intVariable, 2); + expect(batch.get(tA.intVariable), equals(2)); + expect(tA.intVariable.unsafeValue, equals(_kIntValue)); + batch.commit(); + }), + equals(true), + ); + + expect(tA.intVariable.unsafeValue, equals(2)); + }); + + test( + 'You can not begin a batch when another locked batch is active with the same key', + () async { + final tA = _A(); + tA.lock((batch) async => Future.microtask(() => null)); + + try { + tA.begin(); + } catch (error) { + expect(error, isA()); + expect( + error.toString(), startsWith('ConflictException: _A(BatchLockKey')); + } + + tA.lock((batch) async => Future.microtask(() => null), lockKey: tA.keyA); + + try { + tA.begin(tA.keyA); + } catch (error) { + expect(error, isA()); + expect(error.toString(), startsWith('ConflictException: _A(keyA')); + expect(error.toString(), endsWith('Custom Conflict Reason')); + } + }); + + test( + 'You can begin a batch when another locked batch is active with a different key', + () async { + final tA = _A(); + tA.lock( + (batch) async => Future.microtask(() => null), + lockKey: tA.keyA, + ); + expect(() => tA.begin(tA.keyB), returnsNormally); + }); + + test( + 'You can lock multiple times, but will wait and execute in order when using the same key', + () async { + final tA = _A(); + final tFA = tA.lock((batch) { + batch.set(tA.intVariable, 4); + batch.commit(); + }); + + final tFB = tA.lock((batch) { + batch.set(tA.intVariable, 5); + batch.commit(); + }); + + final tFC = tA.lock((batch) { + batch.set(tA.intVariable, 6); + batch.commit(); + }); + + await Future.wait([tFA, tFB, tFC]); + + expect(tA.intVariable.unsafeValue, equals(6)); + }); + + test('If a lock throws, other awaiting locks will execute normally', + () async { + final tA = _A(); + + tA.lock((batch) { + batch.set(tA.intVariable, 4); + batch.commit(); + }); + + tA.lock((batch) async { + throw Error(); + }).catchError((error) => false); + + final tFC = tA.lock((batch) { + batch.set(tA.intVariable, 6); + batch.commit(); + }); + + await tFC; + + expect(tA.intVariable.unsafeValue, equals(6)); + }); + + test( + 'You can have two locks, with various override and overridable settings.', + () async { + const tInputs = [ + [true, true, true, true], + [false, true, true, true], + [true, false, true, true], + [false, false, true, true], + // + [true, true, false, true], + [false, true, false, true], + [true, false, false, true], + [false, false, false, true], + // + [true, true, true, false], + [false, true, true, false], + [true, false, true, false], + [false, false, true, false], + // + [true, true, false, false], + [false, true, false, false], + [true, false, false, false], + [false, false, false, false], + ]; + const tExpects = [ + [1, false, true, 'null1'], + [1, false, true, 'null1'], + [2, true, true, 'null01'], + [2, true, true, 'null01'], + // + [2, true, true, 'null01'], + [2, true, true, 'null01'], + [2, true, true, 'null01'], + [2, true, true, 'null01'], + // + [1, false, true, 'null1'], + [1, false, true, 'null1'], + [2, true, true, 'null01'], + [2, true, true, 'null01'], + // + [2, true, true, 'null01'], + [2, true, true, 'null01'], + [2, true, true, 'null01'], + [2, true, true, 'null01'], + ]; + + for (var i = 0; i < tInputs.length; i++) { + final tInput = tInputs[i]; + final tExpect = tExpects[i]; + final tA = _A(); + tA.addListener(expectAsync0(() {}, count: tExpect[0] as int)); + + final tResultA = tA.lock((batch) async { + batch.set(tA.stringVariable, '${batch.get(tA.stringVariable)}0'); + await Future.microtask(() => null); + batch.commit(); + }, override: tInput[0], overridable: tInput[1]); + + final tResultB = tA.lock((batch) async { + batch.set(tA.stringVariable, '${batch.get(tA.stringVariable)}1'); + await Future.microtask(() => null); + await Future.microtask(() => null); + batch.commit(); + }, override: tInput[2], overridable: tInput[3]); + + expect(await tResultA, equals(tExpect[1] as bool)); + expect(await tResultB, equals(tExpect[2] as bool)); + expect(tA.stringVariable.unsafeValue, tExpect[3] as String); + } + }); + + test('An overriden lock has a chance to revert its changes with onOverride.', + () async { + final tA = _A(); + + var tValue = 0; + + tA.lock( + (batch) { + batch.set(tA.intVariable, 4); + tValue = 1; + batch.commit(); + }, + onOverride: () { + tValue = 2; + }, + ); + + final tFC = tA.lock((batch) { + batch.set(tA.intVariable, 6); + batch.commit(); + }, override: true); + + await tFC; + + expect(tValue, equals(2)); + expect(tA.intVariable.unsafeValue, equals(6)); + }); + + test( + 'An overriden lock has a chance to revert its changes with onOverride asynchronously.', + () async { + final tA = _A(); + + var tValue = 0; + + tA.lock( + (batch) { + batch.set(tA.intVariable, 4); + tValue = 1; + batch.commit(); + }, + onOverride: () async { + await Future.microtask(() => null); + tValue = 2; + }, + ); + + final tFC = tA.lock((batch) { + batch.set(tA.intVariable, 6); + batch.commit(); + }, override: true); + + await tFC; + + expect(tValue, equals(2)); + expect(tA.intVariable.unsafeValue, equals(6)); + }); + + test('You can override multiple times without anything locking up', () async { + final tA = _A(); + + var tValue = 0; + + for (var i = 0; i < 12; i++) { + tA.lock( + (batch) async { + await batch.blockOverride(() => + Future.delayed(const Duration(milliseconds: 45), () => null)); + batch.set(tA.intVariable, 4); + tValue = 1; + batch.commit(); + }, + override: true, + overridable: true, + ); + } + + final tFC = tA.lock((batch) async { + await batch.blockOverride(() => Future.microtask(() => null)); + batch.set(tA.intVariable, 6); + batch.commit(); + }, override: true); + + await tFC; + + expect(tValue, equals(0)); + expect(tA.intVariable.unsafeValue, equals(6)); + }); + + test('You can have a lock in a lock with a different key', () async { + final tA = _A(); + expect( + await tA.lock((batch) async { + final tAS = _A(); + expect( + await tAS.lock((batch) async { + await Future.microtask(() => null); + batch.commit(); + }, lockKey: tAS.keyB), + equals(true), + ); + }, lockKey: tA.keyA), + equals(true), + ); + }); + + test('Listeners are notified when variables change', () { + final tA = _A(); + + tA.intVariable.addListener(expectAsync0(() {}, count: 1)); + tA.addListener(expectAsync0(() {}, count: 1)); + + tA.begin() + ..set(tA.intVariable, 10) + ..commit(); + }); + + test( + 'Listeners are not notified even when variables change if commit\'s notify is false', + () { + final tA = _A(); + + tA.intVariable.addListener(expectAsync0(() {}, count: 0)); + tA.addListener(expectAsync0(() {}, count: 0)); + + tA.begin() + ..set(tA.intVariable, 10) + ..commit(notify: false); + }); + + test( + 'Listeners are always notified even when variables do not change if commit\'s notify is true', + () { + final tA = _A(); + + tA.intVariable.addListener(expectAsync0(() {}, count: 0)); + tA.addListener(expectAsync0(() {}, count: 0)); + + tA.begin() + ..set(tA.intVariable, tA.intVariable.unsafeValue) + ..commit(notify: true); + }); + + test('A variable can be created unset', () { + final tA = _A(); + expect( + () => tA.customUnsetVariable.unsafeValue, + throwsAssertionError, + ); + expect( + tA.customUnsetVariable.toString(), + equals('_CustomType:NOT SET'), + ); + }); + + test('A variable can be created unset, but set later and accessed', () { + final tA = _A(); + tA.begin() + ..set(tA.customUnsetVariable, _CustomType()) + ..commit(); + expect( + tA.customUnsetVariable.unsafeValue, + isNotNull, + ); + expect( + tA.customUnsetVariable.toString(), + equals('_CustomType:CustomType'), + ); + }); + + test( + 'A variable can be created unset or set, and checked in a batch to see if it is set', + () async { + final tA = _A(); + + await tA.lock((batch) { + expect(tA.customUnsetVariable.set, isFalse); + expect(batch.isSet(tA.customUnsetVariable), isFalse); + batch.set(tA.customUnsetVariable, _CustomType()); + expect(batch.isSet(tA.customUnsetVariable), isTrue); + expect(tA.customUnsetVariable.set, isFalse); + batch.commit(); + expect(tA.customUnsetVariable.set, isTrue); + }); + }); + + test('You can get the value asynchronously from a variable', () async { + final tA = _A(); + + expect(await tA.intVariable.getValue(), equals(_kIntValue)); + + final tFuture = tA.lock((batch) async { + await Future.delayed(const Duration(milliseconds: 1)); + batch.set(tA.intVariable, 3); + await Future.delayed(const Duration(milliseconds: 1)); + batch.commit(); + }); + + final tValueFuture = tA.intVariable.getValue(); + + await tFuture; + + expect(await tValueFuture, equals(3)); + }); + + test( + 'You can get the value asynchronously from a variable when multiple batches are ongoing', + () async { + final tA = _A(); + + final tFutures = [ + tA.lock((batch) async { + await Future.delayed(const Duration(milliseconds: 1)); + batch.set(tA.intVariable, batch.get(tA.intVariable) + 1); + await Future.delayed(const Duration(milliseconds: 1)); + batch.commit(); + }), + tA.lock((batch) async { + await Future.delayed(const Duration(milliseconds: 1)); + batch.set(tA.intVariable, batch.get(tA.intVariable) + 1); + await Future.delayed(const Duration(milliseconds: 1)); + batch.commit(); + }), + tA.lock((batch) async { + await Future.delayed(const Duration(milliseconds: 1)); + batch.set(tA.intVariable, batch.get(tA.intVariable) + 1); + await Future.delayed(const Duration(milliseconds: 1)); + batch.commit(); + }), + ]; + + final tValueFuture = tA.intVariable.getValue(); + + await Future.wait(tFutures); + + expect(await tValueFuture, equals(4)); + }); + + test('Listeners are notified when an unset variable is set to the same value', + () { + final tA = _A(); + + tA.stringUnsetVariable.addListener(expectAsync0(() {}, count: 1)); + tA.addListener(expectAsync0(() {}, count: 1)); + + tA.begin() + ..set(tA.stringUnsetVariable, null) + ..commit(); + }); + + test('A model can be disposed', () { + final tA = _A(); + + final tSubscription = tA.use((model) => model.ii); + + expect(() => tA.dispose(), returnsNormally); + expect(tA.disposed, isTrue); + expect(() => tA.begin(), throwsAssertionError); + expect(() => tA.lock((_) {}), throwsAssertionError); + expect(() => tA.use((_) {}), throwsAssertionError); + + expect(() => tSubscription.cancel(), returnsNormally); + }); + + test('A model can be disposed while a batch is running', () async { + final tA = _A(); + + final tLockFuture = tA.lock((batch) async { + await Future.delayed(const Duration(milliseconds: 1)); + batch.commit(); + }); + + expect(() => tA.dispose(), returnsNormally); + expect(tA.disposed, isTrue); + expect(await tA.intVariable.getValue(), _kIntValue); + expect(await tLockFuture, isFalse); + }); + + test( + 'A value of a variable will return it\'s value immediately if a model was disposed of during a batch', + () async { + final tA = _A(); + + final tLockFuture = tA.lock((batch) async { + await Future.delayed(const Duration(milliseconds: 1)); + batch.set(tA.intVariable, 10); + batch.commit(); + }); + + final tValueFuture = tA.intVariable.getValue(); + + final tLockFuture2 = tA.lock((batch) async { + await Future.delayed(const Duration(milliseconds: 1)); + batch.set(tA.intVariable, 20); + batch.commit(); + }); + + expect(() => tA.dispose(), returnsNormally); + expect(tA.disposed, isTrue); + + expect(await tValueFuture, _kIntValue); + expect(await tLockFuture, isFalse); + expect(await tLockFuture2, isFalse); + }); + + test('A model can be used', () async { + final tA = _A(); + + final tSubscription = tA.use( + expectAsync1( + (_A model) => model.ii, + count: 3, + ), + ); + + await tA.lock((batch) { + batch.set(tA.intVariable, 2); + batch.commit(); + }); + + await tA.lock((batch) { + batch.set(tA.intVariable, 3); + batch.commit(); + }); + + expect(tA.ii, equals(3)); + + tSubscription.cancel(); + + await tA.lock((batch) { + batch.set(tA.intVariable, 4); + batch.commit(); + }); + + expect(tA.ii, equals(4)); + }); + + test('A model can be used but the callback is called asynchronously', + () async { + final tA = _A(); + + final tSubscription = tA.use( + expectAsync1( + (_A model) => model.ii, + count: 2, + ), + ); + + tA.begin() + ..set(tA.intVariable, 2) + ..commit(); + + await tA.lock((batch) { + batch.set(tA.intVariable, 3); + batch.commit(); + }); + + expect(tA.ii, equals(3)); + + tSubscription.cancel(); + }); + + test('use can not be called inside use', () async { + final tA = _A(); + + tA.use( + (_A model) { + expect(() => model.use((_A model) => model.ii), throwsAssertionError); + }, + ); + }); +} + +const int _kIntValue = 1; +const String? _kStringNullValue = null; +const Map _kMapValue = {}; + +class _A extends ProviderModel<_A> { + late final intVariable = createVariable(_kIntValue); + late final stringVariable = createVariable(_kStringNullValue); + late final mapVariable = createVariable>(_kMapValue); + late final customVariable = createVariable<_CustomType>(_CustomType()); + late final customUnsetVariable = createUnsetVariable<_CustomType>(); + late final stringUnsetVariable = createUnsetVariable(); + + int get ii => intVariable.unsafeValue; + + final keyA = + ProviderLockKey('keyA', conflictReason: 'Custom Conflict Reason'); + final keyB = ProviderLockKey('keyB'); +} + +class _CustomType { + @override + String toString() => 'CustomType'; +} diff --git a/packages/patapata_core/test/proxy_local_config_test.dart b/packages/patapata_core/test/proxy_local_config_test.dart new file mode 100644 index 0000000..ca7c519 --- /dev/null +++ b/packages/patapata_core/test/proxy_local_config_test.dart @@ -0,0 +1,196 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/src/native_local_config_finder.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + testSetMockMethodCallHandler = TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger.setMockMethodCallHandler; + + const kBoolValueKey = 'kBoolValueKey'; + const kDoubleValueKey = 'kDoubleValueKey'; + const kIntValueKey = 'kIntValueKey'; + const kStringValueKey = 'kStringValueKey'; + + test('initialized | disposed', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + await tProxyLocalConfig.init(); + expect(tProxyLocalConfig.initialized, isTrue); + + tProxyLocalConfig.dispose(); + expect(tProxyLocalConfig.disposed, isTrue); + }); + + test('hasKey', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + const String kDefaultValueKey = 'defaultValue'; + const String kSetValueKey = 'setValue'; + + expect(tProxyLocalConfig.hasKey(kDefaultValueKey), isFalse); + expect(tProxyLocalConfig.hasKey(kSetValueKey), isFalse); + + await tProxyLocalConfig.setDefaults({kDefaultValueKey: 1}); + + expect(tProxyLocalConfig.hasKey(kDefaultValueKey), isFalse); + expect(tProxyLocalConfig.hasKey(kSetValueKey), isFalse); + + await tProxyLocalConfig.setInt(kSetValueKey, 1); + + expect(tProxyLocalConfig.hasKey(kDefaultValueKey), isFalse); + expect(tProxyLocalConfig.hasKey(kSetValueKey), isTrue); + }); + + test('addLocalConfig | removeLocalConfig', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + NativeLocalConfig tNativeLocalConfig = NativeLocalConfig(); + await tNativeLocalConfig.init(); + + await tProxyLocalConfig.setString(kStringValueKey, 'ProxyLocalConfig'); + expect(tProxyLocalConfig.getString(kStringValueKey), 'ProxyLocalConfig'); + + await tProxyLocalConfig.addLocalConfig(tNativeLocalConfig); + await tNativeLocalConfig.setString(kStringValueKey, 'NativeLocalConfig'); + + expect(tProxyLocalConfig.getString(kStringValueKey), 'NativeLocalConfig'); + + tProxyLocalConfig.removeLocalConfig(tNativeLocalConfig); + expect(tProxyLocalConfig.getString(kStringValueKey), 'ProxyLocalConfig'); + }); + + test('check bool value', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + expect( + tProxyLocalConfig.getBool(kBoolValueKey), Config.defaultValueForBool); + + await tProxyLocalConfig.setDefaults({kBoolValueKey: true}); + expect(tProxyLocalConfig.getBool(kBoolValueKey), true); + + await tProxyLocalConfig.setBool(kBoolValueKey, false); + expect(tProxyLocalConfig.getBool(kBoolValueKey), false); + + await tProxyLocalConfig.reset(kBoolValueKey); + expect(tProxyLocalConfig.getBool(kBoolValueKey), true); + }); + + test('check double value', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + expect(tProxyLocalConfig.getDouble(kDoubleValueKey), + Config.defaultValueForDouble); + + await tProxyLocalConfig.setDefaults({kDoubleValueKey: 1.0}); + expect(tProxyLocalConfig.getDouble(kDoubleValueKey), 1.0); + + await tProxyLocalConfig.setDouble(kDoubleValueKey, 2.0); + expect(tProxyLocalConfig.getDouble(kDoubleValueKey), 2.0); + + await tProxyLocalConfig.reset(kDoubleValueKey); + expect(tProxyLocalConfig.getDouble(kDoubleValueKey), 1.0); + }); + + test('check int value', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + expect(tProxyLocalConfig.getInt(kIntValueKey), Config.defaultValueForInt); + + await tProxyLocalConfig.setDefaults({kIntValueKey: 1}); + expect(tProxyLocalConfig.getInt(kIntValueKey), 1); + + await tProxyLocalConfig.setInt(kIntValueKey, 2); + expect(tProxyLocalConfig.getInt(kIntValueKey), 2); + + await tProxyLocalConfig.reset(kIntValueKey); + expect(tProxyLocalConfig.getInt(kIntValueKey), 1); + }); + + test('check String value', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + expect(tProxyLocalConfig.getString(kStringValueKey), + Config.defaultValueForString); + + await tProxyLocalConfig.setDefaults({kStringValueKey: '1'}); + expect(tProxyLocalConfig.getString(kStringValueKey), '1'); + + await tProxyLocalConfig.setString(kStringValueKey, '2'); + expect(tProxyLocalConfig.getString(kStringValueKey), '2'); + + await tProxyLocalConfig.reset(kStringValueKey); + expect(tProxyLocalConfig.getString(kStringValueKey), '1'); + }); + + test('resetAll', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + await tProxyLocalConfig.setBool(kBoolValueKey, false); + await tProxyLocalConfig.setDouble(kDoubleValueKey, 2.0); + await tProxyLocalConfig.setInt(kIntValueKey, 2); + await tProxyLocalConfig.setString(kStringValueKey, '2'); + + expect(tProxyLocalConfig.getBool(kBoolValueKey), false); + expect(tProxyLocalConfig.getDouble(kDoubleValueKey), 2.0); + expect(tProxyLocalConfig.getInt(kIntValueKey), 2); + expect(tProxyLocalConfig.getString(kStringValueKey), '2'); + + await tProxyLocalConfig.resetAll(); + + expect( + tProxyLocalConfig.getBool(kBoolValueKey), Config.defaultValueForBool); + expect(tProxyLocalConfig.getDouble(kDoubleValueKey), + Config.defaultValueForDouble); + expect(tProxyLocalConfig.getInt(kIntValueKey), Config.defaultValueForInt); + expect(tProxyLocalConfig.getString(kStringValueKey), + Config.defaultValueForString); + }); + + test('resetMany', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + const kIsSetKey1 = 'isSet1'; + const kIsSetKey2 = 'isSet2'; + const kIsNotSetKey1 = 'isNotSet1'; + const kIsNotSetKey2 = 'isNotSet2'; + + await tProxyLocalConfig.setDefaults({ + kIsSetKey1: 1, + kIsSetKey2: 1, + }); + + await tProxyLocalConfig.setInt(kIsSetKey1, 2); + await tProxyLocalConfig.setInt(kIsNotSetKey1, 2); + + await tProxyLocalConfig.resetMany([ + kIsSetKey1, + kIsSetKey2, + kIsNotSetKey1, + kIsNotSetKey2, + ]); + + expect(tProxyLocalConfig.getInt(kIsSetKey1), 1); + expect(tProxyLocalConfig.getInt(kIsSetKey2), 1); + expect(tProxyLocalConfig.getInt(kIsNotSetKey1), Config.defaultValueForInt); + expect(tProxyLocalConfig.getInt(kIsNotSetKey2), Config.defaultValueForInt); + }); + + test('setMany', () async { + ProxyLocalConfig tProxyLocalConfig = ProxyLocalConfig(); + + final Map tObjects = { + 'name': 'GREE, Inc.', + }; + + await tProxyLocalConfig.setMany(tObjects); + + expect(tProxyLocalConfig.getString('name'), 'GREE, Inc.'); + }); +} diff --git a/packages/patapata_core/test/remote_config_test.dart b/packages/patapata_core/test/remote_config_test.dart new file mode 100644 index 0000000..11d1394 --- /dev/null +++ b/packages/patapata_core/test/remote_config_test.dart @@ -0,0 +1,334 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const kDefaultBoolKey = 'kDefaultBoolKey'; + const kDefaultDoubleKey = 'kDefaultDoubleKey'; + const kDefaultIntKey = 'kDefaultIntKey'; + const kDefaultStringKey = 'kDefaultStringKey'; + + const kBoolValueKey = 'kBoolValueKey'; + const kDoubleValueKey = 'kDoubleValueKey'; + const kIntValueKey = 'kIntValueKey'; + const kStringValueKey = 'kStringValueKey'; + + late App app; + late MockRemoteConfig mockRemoteConfig; + setUp(() { + mockRemoteConfig = MockRemoteConfig({}); + mockRemoteConfig.testSetMockFetchValues({}); + + final tPlugin = Plugin.inline( + name: 'test', + requireRemoteConfig: true, + createRemoteConfig: () => mockRemoteConfig, + ); + + app = createApp( + plugins: [ + tPlugin, + ], + ); + }); + + tearDown(() { + app.dispose(); + }); + + Future runApp() async { + app.run(); + await App.appStageChangeStream.firstWhere( + (element) { + return element.stage == AppStage.running; + }, + ); + } + + test('Function hasKey.', () async { + await runApp(); + + expect( + app.remoteConfig.hasKey(kBoolValueKey), + false, + ); + + mockRemoteConfig.testSetMockFetchValues({ + kBoolValueKey: true, + }); + + await app.remoteConfig.fetch( + force: true, + ); + + expect( + app.remoteConfig.hasKey(kBoolValueKey), + true, + ); + }); + + test('Function setDefaults.', () async { + await runApp(); + + expect(app.remoteConfig.getBool(kDefaultBoolKey), false); + expect(app.remoteConfig.getDouble(kDefaultDoubleKey), 0.0); + expect(app.remoteConfig.getInt(kDefaultIntKey), 0); + expect(app.remoteConfig.getString(kDefaultStringKey), ''); + + await app.remoteConfig.setDefaults({ + kDefaultBoolKey: true, + kDefaultDoubleKey: 1.0, + kDefaultIntKey: 1, + kDefaultStringKey: '1', + }); + + expect(mockRemoteConfig.getBool(kDefaultBoolKey), true); + expect(mockRemoteConfig.getDouble(kDefaultDoubleKey), 1.0); + expect(mockRemoteConfig.getInt(kDefaultIntKey), 1); + expect(mockRemoteConfig.getString(kDefaultStringKey), '1'); + }); + + test('Function reset.', () async { + await runApp(); + mockRemoteConfig.testSetMockFetchValues({ + kBoolValueKey: true, + kDoubleValueKey: 1.0, + kIntValueKey: 1, + kStringValueKey: '1', + }); + + await app.remoteConfig.fetch( + force: true, + ); + + expect(app.remoteConfig.hasKey(kBoolValueKey), true); + expect(app.remoteConfig.hasKey(kDoubleValueKey), true); + expect(app.remoteConfig.hasKey(kIntValueKey), true); + expect(app.remoteConfig.hasKey(kStringValueKey), true); + + await mockRemoteConfig.reset(kBoolValueKey); + await mockRemoteConfig.reset(kDoubleValueKey); + await mockRemoteConfig.reset(kIntValueKey); + await mockRemoteConfig.reset(kStringValueKey); + + expect(app.remoteConfig.hasKey(kBoolValueKey), false); + expect(app.remoteConfig.hasKey(kDoubleValueKey), false); + expect(app.remoteConfig.hasKey(kIntValueKey), false); + expect(app.remoteConfig.hasKey(kStringValueKey), false); + }); + + test('Function resetAll.', () async { + await runApp(); + mockRemoteConfig.testSetMockFetchValues({ + kBoolValueKey: true, + kDoubleValueKey: 1.0, + kIntValueKey: 1, + kStringValueKey: '1', + }); + + await app.remoteConfig.fetch( + force: true, + ); + + expect(app.remoteConfig.hasKey(kBoolValueKey), true); + expect(app.remoteConfig.hasKey(kDoubleValueKey), true); + expect(app.remoteConfig.hasKey(kIntValueKey), true); + expect(app.remoteConfig.hasKey(kStringValueKey), true); + + await mockRemoteConfig.resetAll(); + + expect(app.remoteConfig.hasKey(kBoolValueKey), false); + expect(app.remoteConfig.hasKey(kDoubleValueKey), false); + expect(app.remoteConfig.hasKey(kIntValueKey), false); + expect(app.remoteConfig.hasKey(kStringValueKey), false); + }); + + test('Function resetMany.', () async { + await runApp(); + mockRemoteConfig.testSetMockFetchValues({ + kBoolValueKey: true, + kDoubleValueKey: 1.0, + kIntValueKey: 1, + kStringValueKey: '1', + }); + + await app.remoteConfig.fetch( + force: true, + ); + + expect(app.remoteConfig.hasKey(kBoolValueKey), true); + expect(app.remoteConfig.hasKey(kDoubleValueKey), true); + expect(app.remoteConfig.hasKey(kIntValueKey), true); + expect(app.remoteConfig.hasKey(kStringValueKey), true); + + await mockRemoteConfig.resetMany([ + kBoolValueKey, + kDoubleValueKey, + kIntValueKey, + kStringValueKey, + ]); + + expect(app.remoteConfig.hasKey(kBoolValueKey), false); + expect(app.remoteConfig.hasKey(kDoubleValueKey), false); + expect(app.remoteConfig.hasKey(kIntValueKey), false); + expect(app.remoteConfig.hasKey(kStringValueKey), false); + }); + + test('Function dispose.', () async { + await runApp(); + + expect(app.remoteConfig.disposed, isFalse); + + app.remoteConfig.dispose(); + expect(app.remoteConfig.disposed, isTrue); + }); + + test('Check bool value.', () async { + await runApp(); + + await app.remoteConfig.setDefaults({ + kDefaultBoolKey: true, + }); + expect(app.remoteConfig.getBool(kDefaultBoolKey), true); + + expect(app.remoteConfig.hasKey(kBoolValueKey), isFalse); + expect( + app.remoteConfig.getBool(kBoolValueKey), + Config.defaultValueForBool, + ); + + mockRemoteConfig.testSetMockFetchValues({ + kBoolValueKey: true, + }); + + await app.remoteConfig.fetch( + force: true, + ); + + expect(app.remoteConfig.hasKey(kBoolValueKey), isTrue); + expect(app.remoteConfig.getBool(kBoolValueKey), true); + + mockRemoteConfig.setBool(kBoolValueKey, false); + expect(app.remoteConfig.getBool(kBoolValueKey), false); + }); + + test('Check double value.', () async { + await runApp(); + + await app.remoteConfig.setDefaults({ + kDefaultDoubleKey: 1.0, + }); + expect(app.remoteConfig.getDouble(kDefaultDoubleKey), 1.0); + + expect(app.remoteConfig.hasKey(kDoubleValueKey), isFalse); + expect( + app.remoteConfig.getDouble(kDoubleValueKey), + Config.defaultValueForDouble, + ); + + mockRemoteConfig.testSetMockFetchValues({ + kDoubleValueKey: 1.0, + }); + + await app.remoteConfig.fetch( + force: true, + ); + + expect(app.remoteConfig.hasKey(kDoubleValueKey), isTrue); + expect(app.remoteConfig.getDouble(kDoubleValueKey), 1.0); + + mockRemoteConfig.setDouble(kDoubleValueKey, 2.0); + expect(app.remoteConfig.getDouble(kDoubleValueKey), 2.0); + }); + test('Check int value.', () async { + await runApp(); + + await app.remoteConfig.setDefaults({ + kDefaultIntKey: 1, + }); + expect(app.remoteConfig.getInt(kDefaultIntKey), 1); + + expect(app.remoteConfig.hasKey(kIntValueKey), isFalse); + expect( + app.remoteConfig.getInt(kIntValueKey), + Config.defaultValueForInt, + ); + + mockRemoteConfig.testSetMockFetchValues({ + kIntValueKey: 1, + }); + + await app.remoteConfig.fetch( + force: true, + ); + + expect(app.remoteConfig.hasKey(kIntValueKey), isTrue); + expect(app.remoteConfig.getInt(kIntValueKey), 1); + + mockRemoteConfig.setInt(kIntValueKey, 2); + expect(app.remoteConfig.getInt(kIntValueKey), 2); + }); + + test('Check String value.', () async { + await runApp(); + + await app.remoteConfig.setDefaults({ + kDefaultStringKey: '1', + }); + expect(app.remoteConfig.getString(kDefaultStringKey), '1'); + + expect(app.remoteConfig.hasKey(kStringValueKey), isFalse); + expect( + app.remoteConfig.getString(kStringValueKey), + Config.defaultValueForString, + ); + + mockRemoteConfig.testSetMockFetchValues({ + kStringValueKey: '1', + }); + + await app.remoteConfig.fetch( + force: true, + ); + + expect(app.remoteConfig.hasKey(kStringValueKey), isTrue); + expect(app.remoteConfig.getString(kStringValueKey), '1'); + + mockRemoteConfig.setString(kStringValueKey, '2'); + expect(app.remoteConfig.getString(kStringValueKey), '2'); + }); + + test('Check many value.', () async { + await runApp(); + + expect(app.remoteConfig.hasKey(kBoolValueKey), false); + expect(app.remoteConfig.hasKey(kDoubleValueKey), false); + expect(app.remoteConfig.hasKey(kIntValueKey), false); + expect(app.remoteConfig.hasKey(kStringValueKey), false); + + await mockRemoteConfig.setMany({ + kBoolValueKey: true, + kDoubleValueKey: 1.0, + kIntValueKey: 1, + kStringValueKey: '1', + }); + + expect(app.remoteConfig.hasKey(kBoolValueKey), true); + expect(app.remoteConfig.hasKey(kDoubleValueKey), true); + expect(app.remoteConfig.hasKey(kIntValueKey), true); + expect(app.remoteConfig.hasKey(kStringValueKey), true); + + expect(app.remoteConfig.getBool(kBoolValueKey), true); + expect(app.remoteConfig.getDouble(kDoubleValueKey), 1.0); + expect(app.remoteConfig.getInt(kIntValueKey), 1); + expect(app.remoteConfig.getString(kStringValueKey), '1'); + }); +} diff --git a/packages/patapata_core/test/remote_messaging_test.dart b/packages/patapata_core/test/remote_messaging_test.dart new file mode 100644 index 0000000..6a3934e --- /dev/null +++ b/packages/patapata_core/test/remote_messaging_test.dart @@ -0,0 +1,317 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('RemoteMessageNotification class.', () { + const tTitle = 'title'; + const tBody = 'body'; + + test('Instance (no arguments).', () async { + const tRemoteMessageNotification = RemoteMessageNotification(); + + expect(tRemoteMessageNotification, isA()); + expect(tRemoteMessageNotification.title, isNull); + expect(tRemoteMessageNotification.body, isNull); + }); + + test('Instance (with arguments).', () async { + const tRemoteMessageNotification = RemoteMessageNotification( + title: tTitle, + body: tBody, + ); + + expect(tRemoteMessageNotification, isA()); + expect(tRemoteMessageNotification.title, tTitle); + expect(tRemoteMessageNotification.body, tBody); + expect( + tRemoteMessageNotification.toString(), + 'RemoteMessageNotification:{title:$tTitle, body:$tBody}', + ); + }); + }); + + group('RemoteMessage class.', () { + const tMessageId = 'messageId'; + const tChannel = 'channel'; + const tData = { + 'key': 'value', + }; + const tRemoteMessageNotification = RemoteMessageNotification(); + + test('Instance (no arguments).', () async { + const tRemoteMessage = RemoteMessage(); + + expect(tRemoteMessage, isA()); + expect(tRemoteMessage.messageId, isNull); + expect( + tRemoteMessage.channel, + RemoteMessage.kRemoteMessageDefaultChannel, + ); + expect(tRemoteMessage.data, isNull); + expect(tRemoteMessage.notification, isNull); + }); + + test('Instance (with arguments).', () async { + const tRemoteMessage = RemoteMessage( + messageId: tMessageId, + channel: tChannel, + data: tData, + notification: tRemoteMessageNotification, + ); + + expect(tRemoteMessage, isA()); + expect(tRemoteMessage.messageId, tMessageId); + expect(tRemoteMessage.channel, tChannel); + expect(tRemoteMessage.data, tData); + expect(tRemoteMessage.notification, tRemoteMessageNotification); + expect( + tRemoteMessage.toString(), + 'RemoteMessage:{messageId:$tMessageId, channel:$tChannel, data:$tData}', + ); + }); + }); + + group('ProxyRemoteMessaging class.', () { + test('Getter app.', () async { + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + + expect(tProxyRemoteMessaging.app, tApp); + }); + + test('Function getInitialMessage (no arguments).', () async { + final tMockRemoteMessaging = MockRemoteMessaging(); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + final tInitialMessage = await tProxyRemoteMessaging.getInitialMessage(); + + expect(tInitialMessage, isNull); + }); + + test('Function getInitialMessage (with arguments).', () async { + const tRemoteMessage = RemoteMessage(); + + final tMockRemoteMessaging = MockRemoteMessaging( + getInitialMessage: () async => tRemoteMessage, + ); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + final tInitialMessage = await tProxyRemoteMessaging.getInitialMessage(); + + expect(tInitialMessage, tRemoteMessage); + }); + + test('Function getToken no RemoteConfigs.', () async { + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + + final tResult = await tProxyRemoteMessaging.getToken(); + + expect(tResult, isNull); + }); + + test('Function getToken with RemoteConfig(no token).', () async { + final tMockRemoteMessaging = MockRemoteMessaging(); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + final tResult = await tProxyRemoteMessaging.getToken(); + + expect(tResult, null); + }); + + test('Function getToken with RemoteConfig(with token).', () async { + const tToken = 'token'; + + final tMockRemoteMessaging = MockRemoteMessaging( + getToken: () async => tToken, + ); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + final tResult = await tProxyRemoteMessaging.getToken(); + + expect(tResult, tToken); + }); + + test("Function getToken with RemoteConfig's.", () async { + const tToken1 = 'token'; + const tToken2 = 'token'; + + final tMockRemoteMessaging1 = MockRemoteMessaging( + getToken: () async => tToken1, + ); + final tMockRemoteMessaging2 = MockRemoteMessaging( + getToken: () async => tToken2, + ); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging1); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging2); + + final tResult = await tProxyRemoteMessaging.getToken(); + + expect(tResult, tToken1); + }); + + test('Stream messageStream.', () async { + final tMockMessagesController = StreamController(); + + final tMockRemoteMessaging = MockRemoteMessaging( + messages: () => tMockMessagesController.stream, + ); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + const tRemoteMessage = RemoteMessage(); + + final tMessageValues = [ + tRemoteMessage, + ]; + + final tProxyMessageFuture = expectLater( + tProxyRemoteMessaging.messages, + emitsInOrder(tMessageValues), + ); + + tMockMessagesController.add(tRemoteMessage); + + await tProxyMessageFuture; + }); + + test('Stream messageStream.', () async { + final tMockMessagesController = StreamController(); + + final tMockRemoteMessaging = MockRemoteMessaging( + messages: () => tMockMessagesController.stream, + ); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + const tRemoteMessage = RemoteMessage(); + + final tMessageValues = [ + tRemoteMessage, + ]; + + final tProxyMessageFuture = expectLater( + tProxyRemoteMessaging.messages, + emitsInOrder(tMessageValues), + ); + + tMockMessagesController.add(tRemoteMessage); + + await tProxyMessageFuture; + }); + + test('Test for tokenStream.', () async { + final tMockTokensController = StreamController(); + + final tMockRemoteMessaging = MockRemoteMessaging( + tokenStream: () => tMockTokensController.stream, + ); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + const tToken = 'token'; + + final tTokenValues = [ + tToken, + ]; + + final tProxyTokenFuture = expectLater( + tProxyRemoteMessaging.tokens, + emitsInOrder(tTokenValues), + ); + + tMockTokensController.add(tToken); + + await tProxyTokenFuture; + }); + + test('Function listenChannel.', () async { + final tMockRemoteMessaging = MockRemoteMessaging(); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + const tChannel = 'channel'; + + expect(await tProxyRemoteMessaging.listenChannel(tChannel), true); + expect(await tProxyRemoteMessaging.listenChannel(tChannel), false); + }); + + test('Function ignoreChannel.', () async { + final tMockRemoteMessaging = MockRemoteMessaging(); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + const tChannel = 'channel'; + await tProxyRemoteMessaging.listenChannel(tChannel); + + expect(await tProxyRemoteMessaging.ignoreChannel(tChannel), true); + expect(await tProxyRemoteMessaging.ignoreChannel(tChannel), false); + }); + + test('Function dispose.', () async { + final tMockRemoteMessaging = MockRemoteMessaging(); + + final tProxyRemoteMessaging = ProxyRemoteMessaging(); + final tApp = createApp(); + await tProxyRemoteMessaging.init(tApp); + await tProxyRemoteMessaging.addRemoteMessaging(tMockRemoteMessaging); + + tProxyRemoteMessaging.dispose(); + + // ignore: invalid_use_of_protected_member + expect(tMockRemoteMessaging.hasListeners, false); + }); + }); +} diff --git a/packages/patapata_core/test/screen_layout_test.dart b/packages/patapata_core/test/screen_layout_test.dart new file mode 100644 index 0000000..5e98bf8 --- /dev/null +++ b/packages/patapata_core/test/screen_layout_test.dart @@ -0,0 +1,842 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:provider/provider.dart'; +import 'utils/patapata_core_test_utils.dart'; + +const String kGetScreenLayoutScaleButton = 'get-screen-layout-scale-button'; +const String kTapGestureDetector = 'tap-gesture-detector'; +const String kRedContainer = 'red-container'; +const String kBlueContainer = 'blue-container'; + +const _testDeviceSizes = [ + Size(375, 667), // iPhone6 + Size(414, 736), // iPhone6Plus + Size(375, 812), // iPhoneX + Size(414, 896), // iPhoneXS MAX + Size(768, 1024), // iPad Air + Size(1668, 2224), // iPad Pro (10.5inch) + Size(1668, 2388), //iPad Pro (11inch) + Size(2048, 2732), // iPad Pro (12.9inch) + Size(1920, 1080), // PC +]; + +late Uint8List _svgBytes; + +class _TestRow extends StatelessWidget { + const _TestRow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 37.5, + height: 300, + child: ColoredBox( + color: Colors.blue.shade100, + ), + ), + SizedBox( + width: 300, + height: 300, + child: ColoredBox( + color: Colors.deepOrange.shade100, + child: Center( + child: SvgPicture.memory( + _svgBytes, + width: 200, + height: 200, + ), + ), + ), + ), + SizedBox( + width: 37.5, + height: 300, + child: ColoredBox( + color: Colors.blue.shade100, + ), + ), + ], + ); + } +} + +class _TestApp extends StatelessWidget { + const _TestApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: _TestScreenLayout(), + ), + ), + ), + ); + } +} + +class _TestScreenLayout extends StatelessWidget { + const _TestScreenLayout({ + Key? key, + this.name, + this.breakpoints, + this.disableScreenLayout = false, + }) : super(key: key); + + final String? name; + final ScreenLayoutBreakpoints? breakpoints; + final bool disableScreenLayout; + + @override + Widget build(BuildContext context) { + return ScreenLayout( + name: name, + breakpoints: breakpoints, + child: disableScreenLayout + ? const ScreenLayoutDisable(child: _TestRow()) + : const _TestRow(), + ); + } +} + +class _AngleChangeNotifier extends ChangeNotifier { + double _angle; + + _AngleChangeNotifier(this._angle); + + double get angle => _angle; + + void rotate() { + _angle -= 90; + if (_angle < 0) { + _angle = 360 - _angle; + } + notifyListeners(); + } +} + +class _TestTransformScreenLayout extends StatelessWidget { + const _TestTransformScreenLayout({ + Key? key, + this.disableScreenLayout = false, + required this.initialRotate, + }) : super(key: key); + + final bool disableScreenLayout; + final double initialRotate; + + Widget _getWidget(_AngleChangeNotifier angleNotifier) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 37.5, + height: 300, + child: ColoredBox( + color: Colors.blue.shade100, + ), + ), + SizedBox( + width: 300, + height: 300, + child: ColoredBox( + color: Colors.deepOrange.shade100, + child: Center( + child: SvgPicture.memory( + _svgBytes, + width: 200, + height: 200, + ), + ), + ), + ), + SizedBox( + width: 37.5, + height: 300, + child: ColoredBox( + color: Colors.blue.shade100, + ), + ), + ], + ), + Align( + alignment: Alignment.topRight, + child: GestureDetector( + onTap: () { + angleNotifier.rotate(); + }, + child: angleNotifier.angle == 90 + ? Container( + key: const ValueKey(kRedContainer), + width: 100, + height: 30, + color: Colors.redAccent, + ) + : Container( + key: const ValueKey(kBlueContainer), + width: 100, + height: 30, + color: Colors.blueAccent, + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (context) => _AngleChangeNotifier(initialRotate), + child: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: Consumer<_AngleChangeNotifier>( + builder: (context, angleNotifier, child) { + return ScreenLayout( + breakpoints: const ScreenLayoutBreakpoints( + portraitStandardBreakpoint: 375, + portraitConstrainedWidth: double.infinity, + landscapeStandardBreakpoint: 375, + landscapeConstrainedWidth: double.infinity, + maxScale: 1.2, + ), + child: Transform.rotate( + angle: angleNotifier.angle * (math.pi / 180), + child: disableScreenLayout + ? ScreenLayoutDisable( + child: _getWidget(angleNotifier), + ) + : _getWidget(angleNotifier), + ), + ); + }, + ), + ), + ), + ), + ), + ); + } +} + +class _ScreenLayoutEnvironment extends Environment + with ScreenLayoutEnvironment { + const _ScreenLayoutEnvironment(); + @override + Map get screenLayoutBreakpoints => { + 'testBreakPoints': const ScreenLayoutBreakpoints( + name: 'normal', + portraitStandardBreakpoint: 375.0, + portraitConstrainedWidth: double.infinity, + landscapeStandardBreakpoint: 375.0, + landscapeConstrainedWidth: double.infinity, + maxScale: 1.2, + ), + }; +} + +class _ScreenLayoutBreakpointsModel extends ChangeNotifier { + ScreenLayoutBreakpoints? _breakpoints = ScreenLayoutDefaultBreakpoints.normal; + + ScreenLayoutBreakpoints? get breakpoints => _breakpoints; + + void setBreakpoints(ScreenLayoutBreakpoints breakpoints) { + _breakpoints = breakpoints; + notifyListeners(); + } +} + +class _ScaleListenableBuilder extends StatelessWidget { + const _ScaleListenableBuilder({ + required this.tScaleNotifier, + }); + + final ValueNotifier tScaleNotifier; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Builder( + builder: (context) => TextButton( + key: const ValueKey(kGetScreenLayoutScaleButton), + onPressed: () { + tScaleNotifier.value = context.screenLayoutScale; + }, + child: ValueListenableBuilder( + valueListenable: tScaleNotifier, + builder: (context, scale, child) => + Text("Test Data Scale : $scale"), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 37.5, + height: 300, + child: ColoredBox( + color: Colors.blue.shade100, + ), + ), + SizedBox( + width: 300, + height: 300, + child: ColoredBox( + color: Colors.deepOrange.shade100, + child: Center( + child: SvgPicture.memory( + _svgBytes, + width: 200, + height: 200, + ), + ), + ), + ), + SizedBox( + width: 37.5, + height: 300, + child: ColoredBox( + color: Colors.blue.shade100, + ), + ), + ], + ), + ], + ); + } +} + +void main() { + setUp(() async { + File svgFile = File('test/assets/images/logo.svg'); + _svgBytes = await svgFile.readAsBytes(); + }); + + group('ScreenLayoutGoldenTests', () { + for (final size in _testDeviceSizes) { + testGoldens('Test ScreenLayout: $size', (WidgetTester tester) async { + await loadAppFonts(); + await tester.pumpWidgetBuilder(const _TestApp(), surfaceSize: size); + await screenMatchesGolden(tester, 'ScreenLayout_$size'); + }); + } + + testGoldens("ScreenLayoutAppStandardTest", (WidgetTester tester) async { + await loadAppFonts(); + + var tTestDeviceSize = _testDeviceSizes[0]; + + final App tApp = createApp( + environment: const _ScreenLayoutEnvironment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: _TestScreenLayout( + name: 'testBreakPoints', + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'ScreenLayout_$tTestDeviceSize'); + }); + + tApp.dispose(); + }); + + testGoldens("ScreenLayoutAppFactoryMethodTest", + (WidgetTester tester) async { + await loadAppFonts(); + + var tTestDeviceSize = _testDeviceSizes[0]; + + final App tApp = createApp( + environment: const _ScreenLayoutEnvironment(), + appWidget: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: ScreenLayout.named( + name: 'testBreakePoints', + child: const _TestRow(), + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'ScreenLayout_$tTestDeviceSize'); + }); + + tApp.dispose(); + }); + + testGoldens('ScreenLayoutBreakPointCopyTest', (WidgetTester tester) async { + await loadAppFonts(); + + var tTestDeviceSize = _testDeviceSizes[0]; + + const tBreakpoints = ScreenLayoutBreakpoints( + name: 'normal', + portraitStandardBreakpoint: 375.0, + portraitConstrainedWidth: 482, + landscapeStandardBreakpoint: 375.0, + landscapeConstrainedWidth: 482, + maxScale: 1.2, + ); + + final tModifiedBreakpoints = tBreakpoints.copyWith( + name: null, + maxScale: null, + ); + + final App tApp = createApp( + environment: const Environment(), + appWidget: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: _TestScreenLayout( + breakpoints: tModifiedBreakpoints, + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'ScreenLayout_BreakPoint_CopyTest'); + }); + + tApp.dispose(); + }); + + testGoldens("ScreenLayoutUpdateRotationTest", (WidgetTester tester) async { + await loadAppFonts(); + + var tTestDeviceSize = _testDeviceSizes[0]; + + final App tApp = createApp( + environment: const Environment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: _TestScreenLayout(), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'ScreenLayout_$tTestDeviceSize'); + + tTestDeviceSize = Size(tTestDeviceSize.height, tTestDeviceSize.width); + + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'ScreenLayout_$tTestDeviceSize'); + }); + + tApp.dispose(); + }); + + testGoldens("ScreenLayoutUpdateBreakpointsTest", + (WidgetTester tester) async { + await loadAppFonts(); + + late _ScreenLayoutBreakpointsModel tProvider = + _ScreenLayoutBreakpointsModel(); + + final App tApp = createApp( + environment: const Environment(), + appWidget: ChangeNotifierProvider<_ScreenLayoutBreakpointsModel>( + create: (BuildContext context) { + return _ScreenLayoutBreakpointsModel(); + }, + child: Builder( + builder: (BuildContext context) { + tProvider = context.watch<_ScreenLayoutBreakpointsModel>(); + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: GestureDetector( + key: const ValueKey(kTapGestureDetector), + onTap: () => tProvider.setBreakpoints( + ScreenLayoutDefaultBreakpoints.large, + ), + child: _TestScreenLayout( + breakpoints: tProvider.breakpoints, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kTapGestureDetector))); + + for (final size in _testDeviceSizes) { + await setTestDeviceSize(tester, size); + + await screenMatchesGolden( + tester, 'ScreenLayout_ChangeBreakPoint_$size'); + } + }); + + tApp.dispose(); + }); + + testGoldens("ScreenLayoutDiableTest", (WidgetTester tester) async { + await loadAppFonts(); + + final App tApp = createApp( + environment: const Environment(), + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: _TestScreenLayout( + disableScreenLayout: true, + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + for (final size in _testDeviceSizes) { + await setTestDeviceSize(tester, size); + + await tester.pumpAndSettle(); + + await screenMatchesGolden(tester, 'ScreenLayout_Disable_$size'); + } + }); + + tApp.dispose(); + }); + + testGoldens("ScreenLayoutApplyPaintTransformTest", + (WidgetTester tester) async { + await loadAppFonts(); + var tTestDeviceSize = _testDeviceSizes[1]; + final App tApp = createApp( + environment: const Environment(), + appWidget: const _TestTransformScreenLayout(initialRotate: 90), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey(kRedContainer)), findsOneWidget); + + await tester.tapAt(const Offset(40, 565)); + + await screenMatchesGolden( + tester, 'ScreenLayout_Transform_$tTestDeviceSize'); + + expect(find.byKey(const ValueKey(kBlueContainer)), findsOneWidget); + }); + + tApp.dispose(); + }); + + testGoldens("ScreenLayoutDisableApplyPaintTransformTest", + (WidgetTester tester) async { + await loadAppFonts(); + var tTestDeviceSize = _testDeviceSizes[1]; + final App tApp = createApp( + environment: const Environment(), + appWidget: const _TestTransformScreenLayout( + disableScreenLayout: true, + initialRotate: 90, + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey(kRedContainer)), findsOneWidget); + + await tester.tapAt(const Offset(58, 567)); + + await screenMatchesGolden( + tester, 'ScreenLayout_Transform_Disable_$tTestDeviceSize'); + + expect(find.byKey(const ValueKey(kBlueContainer)), findsOneWidget); + }); + + tApp.dispose(); + }); + }); + + group('ScreenLayoutWidgetTests', () { + testWidgets("ScreenLayoutGetScreenLayoutScaleTest", + (WidgetTester tester) async { + final ValueNotifier tScaleNotifier = ValueNotifier(-1.0); + + await loadAppFonts(); + + var tTestDeviceSize = _testDeviceSizes[4]; + + final App tApp = createApp( + environment: const Environment(), + appWidget: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: ScreenLayout( + child: _ScaleListenableBuilder( + tScaleNotifier: tScaleNotifier, + ), + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + await tester + .tap(find.byKey(const ValueKey(kGetScreenLayoutScaleButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Data Scale : 1.2'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("ScreenLayoutDisableGetScreenLayoutScaleTest", + (WidgetTester tester) async { + final ValueNotifier tScaleNotifier = ValueNotifier(-1.0); + + await loadAppFonts(); + + var tTestDeviceSize = _testDeviceSizes[4]; + + final App tApp = createApp( + environment: const Environment(), + appWidget: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: ScreenLayoutDisable( + child: _ScaleListenableBuilder( + tScaleNotifier: tScaleNotifier, + ), + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + await tester + .tap(find.byKey(const ValueKey(kGetScreenLayoutScaleButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Data Scale : 1.0'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets('ScreenLayout ConstrainedWidth Zero', + (WidgetTester tester) async { + final ValueNotifier tScaleNotifier = ValueNotifier(-1.0); + + await loadAppFonts(); + + var tTestDeviceSize = _testDeviceSizes[4]; + + final App tApp = createApp( + environment: const Environment(), + appWidget: MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + backgroundColor: Colors.white, + body: SizedBox.expand( + child: Center( + child: ScreenLayout( + breakpoints: const ScreenLayoutBreakpoints( + name: 'normal', + portraitStandardBreakpoint: 375.0, + portraitConstrainedWidth: 0, + landscapeStandardBreakpoint: 375.0, + landscapeConstrainedWidth: 0, + maxScale: 1.2, + ), + child: _ScaleListenableBuilder( + tScaleNotifier: tScaleNotifier, + ), + ), + ), + ), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await setTestDeviceSize(tester, tTestDeviceSize); + + await tester.pumpAndSettle(); + + await tester + .tap(find.byKey(const ValueKey(kGetScreenLayoutScaleButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Data Scale : 1.0'), findsOneWidget); + }); + + tApp.dispose(); + }); + }); + + group('ScreenLayoutUnitTests', () { + test('Test for identical hash codes of ScreenLayoutBreakpoints', () { + final breakpoints1 = const ScreenLayoutBreakpoints( + name: 'Test Breakpoint', + portraitStandardBreakpoint: 600.0, + portraitConstrainedWidth: 800.0, + landscapeStandardBreakpoint: 900.0, + landscapeConstrainedWidth: 1200.0, + maxScale: 1.5, + ).hashCode; + + final breakpoints2 = const ScreenLayoutBreakpoints( + name: 'Test Breakpoint', + portraitStandardBreakpoint: 600.0, + portraitConstrainedWidth: 800.0, + landscapeStandardBreakpoint: 900.0, + landscapeConstrainedWidth: 1200.0, + maxScale: 1.5, + ).hashCode; + + expect(breakpoints1, breakpoints2); + }); + }); +} diff --git a/packages/patapata_core/test/sequential_work_queue_test.dart b/packages/patapata_core/test/sequential_work_queue_test.dart new file mode 100644 index 0000000..5fbf041 --- /dev/null +++ b/packages/patapata_core/test/sequential_work_queue_test.dart @@ -0,0 +1,1358 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/src/sequential_work_queue.dart'; + +void main() { + group('SequentialWorkQueue', () { + group('basics', () { + test('should execute work in order', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + }); + + tQueue.add(() { + tResults.add(2); + }); + + await tQueue.add(() async { + tResults.add(3); + }); + + expect(tResults, [1, 2, 3]); + }); + + test('should handle work that throws an error', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + }); + + expect(tQueue.add(() async { + throw Exception('Error'); + }), throwsA(isA())); + + expect(tQueue.add(() { + throw Exception('Error2'); + }), throwsA(isA())); + + await tQueue.add(() async { + tResults.add(3); + }); + + expect(tResults, [1, 3]); + }); + + test('should support cancelling work', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + await tQueue.add(() async { + tResults.add(1); + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(2); + }); + + await tQueue.clear(); + + expect(tResults, [1]); + }); + + test('should execute onCancel callback when cancelling work', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(1); + }, () { + tResults.add(0); + return true; + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(2); + }); + + await tQueue.clear(); + + expect(tResults, [0]); + }); + + test('should not execute onCancel callback when not cancelling work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(2); + }, () { + tResults.add(0); + return false; + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(3); + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(4); + }, () { + tResults.add(1); + return false; + }); + + await tQueue.clear(); + + expect(tResults, [0, 1, 2, 4]); + }); + + test('should execute onCancel callback that returns a future', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(1); + }, () async { + tResults.add(0); + await Future.microtask(() => null); + return true; + }); + + tQueue.add(() async { + tResults.add(2); + }); + + await tQueue.clear(); + + expect(tResults, [0]); + }); + + test('should handle work that returns a value', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + int? tValue = await tQueue.add(() async { + tResults.add(1); + return 42; + }); + + expect(tValue, 42); + + tValue = await tQueue.add(() { + tResults.add(2); + return 43; + }); + + expect(tValue, 43); + expect(tResults, [1, 2]); + }); + + test('should handle work that returns a future', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + final tValue = await tQueue.add(() async { + tResults.add(1); + return await Future.microtask(() => 42); + }); + + expect(tResults, [1]); + expect(tValue, 42); + }); + + test('isEmpty should return true when empty', () async { + final tQueue = SequentialWorkQueue(); + + expect(tQueue.isEmpty, isTrue); + + tQueue.add(() async {}); + + expect(tQueue.isEmpty, isFalse); + + await tQueue.add(() async {}); + + expect(tQueue.isEmpty, isTrue); + }); + + test('isNotEmpty should return false when empty', () async { + final tQueue = SequentialWorkQueue(); + + expect(tQueue.isNotEmpty, isFalse); + + tQueue.add(() async {}); + + expect(tQueue.isNotEmpty, isTrue); + + await tQueue.add(() async {}); + + expect(tQueue.isNotEmpty, isFalse); + }); + + test('insideSequentialWorkQueue should return true when inside', + () async { + final tQueue = SequentialWorkQueue(); + + expect(tQueue.insideSequentialWorkQueue, isFalse); + + await tQueue.add(() async { + expect(tQueue.insideSequentialWorkQueue, isTrue); + }); + + expect(tQueue.insideSequentialWorkQueue, isFalse); + + final tFuture = tQueue.add(() async { + expect(tQueue.insideSequentialWorkQueue, isTrue); + }); + + expect(tQueue.insideSequentialWorkQueue, isFalse); + + await tFuture; + }); + + test('can use custom zones inside of work', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + await tQueue.add(() async { + tResults.add(1); + + await runZonedGuarded( + () async { + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }, + (error, stackTrace) { + tResults.add(4); + }, + ); + + tResults.add(5); + }); + + expect(tResults, [1, 2, 3, 5]); + }); + + test('can use custom zones inside of work that throws an error', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + expect( + () async => await tQueue.add(() async { + tResults.add(1); + + final tFuture = runZoned(() async { + tResults.add(2); + await Future.microtask(() => null); + throw Exception('Error'); + }); + expect(tFuture, throwsA(isA())); + + await tFuture; + }), + throwsA(isA()), + ); + + expect(tResults, [1, 2]); + }); + + test( + 'can use custom zones and still cancel work. but the zone work should not be cancelled', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + + await runZonedGuarded( + () async { + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }, + (error, stackTrace) { + tResults.add(4); + }, + ); + + tResults.add(5); + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(6); + }); + + await tQueue.clear(); + + expect(tResults, [1, 2, 3, 5]); + }); + }); + + group('microtasks', () { + test('should be able to create microtasks inside of work', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + await tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() async { + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }); + + tResults.add(4); + }); + + expect(tResults, [1, 2, 3, 4]); + }); + + test('should be able to create microtasks inside of work that throws', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + await expectLater( + tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() async { + tResults.add(2); + await Future.microtask(() => null); + throw Exception('Error'); + }); + }), + throwsA(isA()), + ); + + expect(tResults, [1, 2]); + }); + + test('should be able to create microtasks, and then cancel work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() async { + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }); + + tResults.add(4); + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(5); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tResults, [1, 2]); + }); + + test( + 'should be able to create microtasks in uncancelable work, and then attempt to cancel the work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() async { + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }); + + tResults.add(4); + }, () { + return false; + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(5); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tResults, [1, 2, 3, 4]); + }); + + test( + 'should be able to create microtasks in future uncancellable work, and then attempt to cancel the work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() async { + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }); + + tResults.add(4); + }, () async { + return false; + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(5); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tResults, [1, 2, 3, 4]); + }); + + test( + 'should be able to create microtasks in future cancellable work, and then cancel the work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() async { + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }); + + tResults.add(4); + }, () async { + return true; + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(5); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tResults, [1, 2]); + }); + + test( + 'should be able to create microtasks in future cancellable work, and then cancel the work. Support errors', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() async { + tResults.add(2); + + throw Exception('Error'); + }); + }, () async { + return true; + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(5); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tResults, [1, 2]); + }); + + test( + 'should be able to create microtasks in future errored cancellable work, and then attempt to cancel the work', + () async { + runZonedGuarded(() async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() async { + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }); + + tResults.add(4); + }, () async { + throw Exception('Error'); + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(5); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tResults, [1, 2, 3, 4]); + }, (error, stackTrace) { + expect(error, isA()); + }); + }); + + test( + 'should be able to create microtasks inside microtasks and they should be cancellable', + () async { + final tQueue = SequentialWorkQueue(); + final tShouldCall = expectAsync0(() => null, count: 4); + final tShouldNotCall = expectAsync0(() => null, count: 0); + int tValue = 0; + + expectLater( + tQueue.add(() async { + tShouldCall(); + Future.microtask(() async { + tShouldCall(); + // One tick + await Future.microtask(tShouldCall); + + tValue = 1; + + // Another tick + return Future.microtask(tShouldNotCall); + }).then((value) { + tShouldNotCall(); + }); + + tShouldCall(); + }), + completes, + ); + + // One tick + await Future.microtask(() => null); + + // Two tick + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tValue, 1); + }); + }); + }); + + group('timers', () { + test('should be able to create timers inside of work', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 1); + + await tQueue.add(() async { + tResults.add(1); + + Timer.run(() async { + await Future.microtask(() => null); + tShouldCall(); + }); + + tResults.add(2); + }); + + expect(tResults, [1, 2]); + }); + + test('should be able to create timers inside of work that throws', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldNotCall = expectAsync0(() => null, count: 0); + + runZonedGuarded(() async { + await expectLater( + tQueue.add(() async { + tResults.add(1); + + Timer.run(() async { + tResults.add(2); + await Future.microtask(() => null); + throw _TestException('SWQ Error'); + // ignore: dead_code + tShouldNotCall(); + }); + + return 1; + }), + completion(1), + ); + + expect(tResults, [1, 2]); + }, (error, stack) { + if (error is _TestException) { + expect(error.message, 'SWQ Error'); + } else { + Error.throwWithStackTrace(error, stack); + } + }); + }); + + test('should be able to create timers, and then cancel work', () async { + runZonedTimer( + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldNotCall = expectAsync0(() => null, count: 0); + + tQueue.add(() async { + tResults.add(1); + + Timer.run(() async { + tResults.add(2); + await Future.microtask(() => null); + tShouldNotCall(); + tResults.add(3); + }); + + tResults.add(4); + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(5); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tResults, [1, 4, 2]); + }, + ); + }); + + test( + 'should be able to create timers in uncancelable work, and then attempt to cancel the work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 1); + final tShouldNotCall = expectAsync0(() => null, count: 0); + + final tFuture = tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() => null); + + Timer.run(() async { + tShouldCall(); + tResults.add(2); + await Future.microtask(() => null); + tResults.add(3); + }); + + await Future.microtask(() => null); + + tResults.add(4); + }, () { + return false; + }); + + tQueue.add(() async { + tResults.add(5); + + Timer.run(() async { + tShouldNotCall(); + tResults.add(6); + await Future.microtask(() => null); + tResults.add(7); + }); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + // One more tick + await Future.microtask(() => null); + + await expectLater(tFuture, completes); + + await tQueue.clear(); + + expect(tResults, [1, 4, 2, 3, 5]); + }); + + test('can use custom zones inside of work', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + await tQueue.add(() async { + tResults.add(1); + + await runZonedGuarded( + () async { + tResults.add(2); + Timer.run(() { + tResults.add(3); + }); + }, + (error, stackTrace) { + tResults.add(4); + }, + ); + + tResults.add(5); + }); + + expect(tResults, [1, 2, 5, 3]); + }); + + test('can use custom zones inside of work but not wait for async', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + await tQueue.add(() async { + tResults.add(1); + + await runZonedGuarded( + () async { + tResults.add(2); + Timer.run(() { + tResults.add(3); + }); + }, + (error, stackTrace) { + tResults.add(4); + }, + zoneValues: { + #sequentialWorkQueueNoWaitForAsync: true, + }, + ); + + tResults.add(5); + }); + + expect(tResults, [1, 2, 5]); + + await tQueue.clear(); + }); + + test('can use a future to cancel work', () async { + runZonedTimer(() async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 1); + final tShouldNotCall = expectAsync0(() => null, count: 0); + + tQueue.add(() async { + tResults.add(1); + + Timer.run(() async { + tShouldNotCall(); + tResults.add(2); + }); + + tResults.add(3); + }, () async { + tResults.add(4); + await Future.microtask(() => null); + tShouldCall(); + return true; + }); + + final tFuture = tQueue.clear(); + // Tick once to let the microtasks run. + await Future.microtask(() => null); + + await tFuture; + + expect(tResults, [1, 3, 4]); + }); + }); + + test('can use a future to not cancel work', () async { + runZonedTimer(() async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 2); + + tQueue.add(() async { + tResults.add(1); + + Timer.run(() async { + tShouldCall(); + tResults.add(2); + }); + + tResults.add(3); + }, () async { + tResults.add(4); + await Future.microtask(() => null); + tShouldCall(); + return false; + }); + + final tFuture = tQueue.clear(); + // Tick once to let the microtasks run. + await Future.microtask(() => null); + + await tFuture; + + expect(tResults, [1, 3, 4, 2]); + }); + }); + + test('can use a future to not cancel work with an error', () async { + final tShouldCall = expectAsync0(() => null, count: 4); + final tShouldNotCall = expectAsync0(() => null, count: 0); + + runZonedGuarded(() { + runZonedTimer(() async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + + tQueue.add(() async { + tResults.add(1); + + Timer.run(() async { + tShouldCall(); + throw _TestException('RealError'); + // ignore: dead_code + tResults.add(2); + }); + + tResults.add(3); + }, () async { + tShouldCall(); + tResults.add(4); + await Future.microtask(() => null); + throw _TestException('CancelError'); + // ignore: dead_code + tShouldNotCall(); + return true; + }); + + final tFuture = tQueue.clear(); + // Tick once to let the microtasks run. + await Future.microtask(() => null); + + await tFuture; + + expect(tResults, [1, 3, 4]); + }); + }, (error, stack) { + if (error is _TestException) { + tShouldCall(); + expect(error.message, anyOf('CancelError', 'RealError')); + } else { + Error.throwWithStackTrace(error, stack); + } + }); + }); + }); + + group('periodic timers', () { + test('should be able to create periodic timers inside of work', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 3); + int tCounter = 0; + + await runZonedTimer(() async { + await tQueue.add(() async { + tResults.add(1); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + await Future.microtask(() => null); + expect(timer.isActive, isTrue); + expect(timer.tick, isNonZero); + tResults.add(2); + + tCounter++; + if (tCounter == 3) { + timer.cancel(); + } + }); + + tResults.add(3); + }); + + expect(tResults, [1, 3, 2, 2, 2]); + }); + }); + + test('should be able to create periodic timers inside of work that throws', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 3); + final tShouldNotCall = expectAsync0(() => null, count: 0); + int tCounter = 0; + + runZonedGuarded(() async { + await runZonedTimer(() async { + await expectLater( + tQueue.add(() async { + tResults.add(1); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + tCounter++; + if (tCounter == 3) { + timer.cancel(); + } + tResults.add(2); + throw _TestException('SWQ Error'); + // ignore: dead_code + tShouldNotCall(); + }); + + return 1; + }), + completion(1), + ); + + expect(tResults, [1, 2, 2, 2]); + }); + }, (error, stack) { + if (error is _TestException) { + expect(error.message, 'SWQ Error'); + } else { + Error.throwWithStackTrace(error, stack); + } + }); + }); + + test('should be able to create periodic timers, and then cancel work', + () async { + await runZonedTimer( + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 1); + final tShouldNotCall = expectAsync0(() => null, count: 0); + int tCounter = 0; + + tQueue.add(() async { + tResults.add(1); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + tCounter++; + if (tCounter == 3) { + tShouldNotCall(); + timer.cancel(); + } + tResults.add(2); + }); + + tResults.add(3); + }); + + tQueue.add(() async { + await Future.microtask(() => null); + tResults.add(4); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + await tQueue.clear(); + + expect(tResults, [1, 3, 2]); + }, + ); + }); + + test( + 'should be able to create periodic timers in uncancelable work, and then attempt to cancel the work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 3); + final tShouldNotCall = expectAsync0(() => null, count: 0); + int tCounter = 0; + + runZonedTimer(() async { + final tFuture = tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() => null); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + tCounter++; + if (tCounter == 3) { + timer.cancel(); + } + tResults.add(2); + }); + + tResults.add(3); + }, () { + return false; + }); + + tQueue.add(() async { + tResults.add(4); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldNotCall(); + tResults.add(5); + }); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + // One more tick + await Future.microtask(() => null); + + await expectLater(tFuture, completes); + + await tQueue.clear(); + + expect(tResults, [1, 3, 2, 2, 2, 4]); + }); + }); + + test( + 'should be able to create periodic timers in future uncancellable work, and then attempt to cancel the work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 3); + final tShouldNotCall = expectAsync0(() => null, count: 0); + int tCounter = 0; + + runZonedTimer(() async { + final tFuture = tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() => null); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + tCounter++; + if (tCounter == 3) { + timer.cancel(); + } + tResults.add(2); + }); + + tResults.add(3); + }, () async { + return false; + }); + + tQueue.add(() async { + tResults.add(4); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldNotCall(); + tResults.add(5); + }); + }); + + // Allow one tick to let the microtasks run. + await Future.microtask(() => null); + + // One more tick + await Future.microtask(() => null); + + await expectLater(tFuture, completes); + + await tQueue.clear(); + + expect(tResults, [1, 3, 2, 2, 2, 4]); + }); + }); + + test( + 'should be able to create periodic timers in future cancellable work, and then cancel the work', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 1); + final tShouldNotCall = expectAsync0(() => null, count: 0); + int tCounter = 0; + + await runZonedTimer(() async { + final tFuture = tQueue.add(() async { + tResults.add(1); + + await Future.microtask(() => null); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + tCounter++; + if (tCounter == 3) { + timer.cancel(); + } + tResults.add(2); + }); + + tResults.add(3); + }, () async { + return true; + }); + + tQueue.add(() async { + tResults.add(4); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldNotCall(); + tResults.add(5); + }); + }); + + // Allow two ticks to let the microtasks run. + await Future.microtask(() => null); + await Future.microtask(() => null); + + expectLater(tFuture, completes); + + await tQueue.clear(); + + expect(tResults, [1, 3, 2]); + }); + }); + + test( + 'should be able to create periodic timers in future cancellable work, and then cancel the work. Support errors', + () async { + final tShouldCall = expectAsync0(() => null, count: 5); + final tShouldNotCall = expectAsync0(() => null, count: 0); + + runZonedGuarded(() async { + await runZonedTimer(() async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + int tCounter = 0; + + tQueue.add(() async { + tResults.add(1); + + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + tCounter++; + if (tCounter == 3) { + timer.cancel(); + } + tResults.add(2); + }); + + tResults.add(3); + }, () async { + tShouldCall(); + tResults.add(4); + await Future.microtask(() => null); + throw _TestException('CancelError'); + // ignore: dead_code + tShouldNotCall(); + return true; + }); + + final tFuture = tQueue.clear(); + // Tick once to let the microtasks run. + await Future.microtask(() => null); + + await tFuture; + + expect(tResults, [1, 3, 4, 2, 2, 2]); + }); + }, (error, stack) { + if (error is _TestException) { + tShouldCall(); + expect(error.message, 'CancelError'); + } else { + Error.throwWithStackTrace(error, stack); + } + }); + }); + + test('can use custom zones inside of work', () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 3); + int tCounter = 0; + + runZonedTimer(() async { + await tQueue.add(() async { + tResults.add(1); + + runZoned( + () { + tResults.add(2); + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + tCounter++; + if (tCounter == 3) { + timer.cancel(); + } + tResults.add(3); + }); + }, + ); + + tResults.add(5); + }); + + expect(tResults, [1, 2, 5, 3, 3, 3]); + }); + }); + + test('can use custom zones inside of work but not wait for async', + () async { + final tQueue = SequentialWorkQueue(); + final tResults = []; + final tShouldCall = expectAsync0(() => null, count: 10); + int tCounter = 0; + + runZonedTimer(() async { + await tQueue.add(() async { + tResults.add(1); + + runZoned( + () { + tResults.add(2); + Timer.periodic(const Duration(milliseconds: 1), (timer) async { + tShouldCall(); + tCounter++; + if (tCounter == 10) { + timer.cancel(); + } + tResults.add(3); + }); + }, + zoneValues: { + #sequentialWorkQueueNoWaitForAsync: true, + }, + ); + + tResults.add(5); + }); + + expect(tResults, [1, 2, 5, 3, 3, 3]); + }); + }); + }); +} + +T runZonedTimer(T Function() callback) { + return runZoned( + callback, + zoneSpecification: ZoneSpecification( + createTimer: (self, parent, zone, duration, f) { + // We want to execute asap. + // We aren't going to cancel any timers either. + Zone.root.scheduleMicrotask(f); + + return parent.createTimer(zone, duration, () {}); + }, + createPeriodicTimer: (self, parent, zone, period, f) { + // We want to execute asap. + final tTimer = _TestPeriodicTimer(); + + void fTick() { + tTimer._tick++; + f(tTimer); + + if (tTimer.isActive) { + Zone.root.scheduleMicrotask(() { + if (tTimer.isActive) { + fTick(); + } + }); + } + } + + Zone.root.scheduleMicrotask(() { + if (tTimer.isActive) { + fTick(); + } + }); + + return tTimer; + }, + ), + ); +} + +class _TestException implements Exception { + final String message; + + _TestException(this.message); + + @override + String toString() { + return message; + } +} + +class _TestPeriodicTimer implements Timer { + @override + void cancel() { + _isActive = false; + } + + bool _isActive = true; + + @override + bool get isActive => _isActive; + + int _tick = 0; + + @override + int get tick => _tick; +} diff --git a/packages/patapata_core/test/standard_app_logic_test.dart b/packages/patapata_core/test/standard_app_logic_test.dart new file mode 100644 index 0000000..d43c76e --- /dev/null +++ b/packages/patapata_core/test/standard_app_logic_test.dart @@ -0,0 +1,72 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('StandardPageFactory instantiation test.', () { + expect( + StandardPageWithResultFactory<_TestPageA, void, int>( + create: (data) => _TestPageA(), + ), + isInstanceOf>(), + ); + }); + + test('StandardPageFactory factory getter test.', () { + // void type check + var tFactory = StandardPageWithResultFactory<_TestPageA, void, int>( + create: (data) => _TestPageA(), + ); + + expect(tFactory.pageType, _TestPageA); + expect(tFactory.dataType, isA()); + expect(tFactory.dataTypeIsNonNullable, false); + expect(tFactory.resultType, int); + + // page data type check + tFactory = StandardPageWithResultFactory<_TestPageA, TestPageDataA, int>( + create: (data) => _TestPageA(), + ); + + expect(tFactory.pageType, _TestPageA); + expect(tFactory.dataType, TestPageDataA); + expect(tFactory.dataTypeIsNonNullable, true); + expect(tFactory.resultType, int); + }); +} + +class TestPageDataA { + final String test = 'test'; +} + +class _TestPageA extends StandardPageWithResult { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, 'title test page A'), + ), + ), + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text("test page A"); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_core/test/standard_app_widget_tab_test.dart b/packages/patapata_core/test/standard_app_widget_tab_test.dart new file mode 100644 index 0000000..8fc8a1f --- /dev/null +++ b/packages/patapata_core/test/standard_app_widget_tab_test.dart @@ -0,0 +1,863 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:provider/provider.dart'; +import 'utils/patapata_core_test_utils.dart'; + +const String _kTestGoSecondPageButton = 'test-go-second-page-button'; +const String _kTestGoHomePageButton = 'test-go-home-page-button'; +const String _kTestGoTitlePageButton = 'test-go-title-page-button'; +const String _kTestGoTitleDetailsPageButton = + 'test-go-title-details-page-button'; +const String _kTestGoMyPageButton = 'test-go-my-page-button'; +const String _kTestGoMyFavoritePageButton = 'test-go-myfavorite-page-button'; +const String _kTestStandardPageBackButton = 'standard-page-back-button'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + testWidgets("Standard Page Test, Child Page Instances (Standard Material)", + (WidgetTester tester) async { + // Standard Material Page Test + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + StandardPageFactory<_TestFirstPage, void>( + create: (data) => _TestFirstPage(), + ), + StandardPageFactory<_TestSecondPage, void>( + create: (data) => _TestSecondPage(), + ), + StandardPageFactory<_TestHomePage, void>( + create: (data) => _TestHomePage(), + ), + StandardPageFactory<_TestTitlePage, void>( + create: (data) => _TestTitlePage(), + parentPageType: _TestHomePage, + ), + StandardPageFactory<_TestTitleDetailsPage, void>( + create: (data) => _TestTitleDetailsPage(), + parentPageType: _TestHomePage, + ), + StandardPageFactory<_TestMyPage, void>( + create: (data) => _TestMyPage(), + ), + StandardPageFactory<_TestMyFavoritePage, void>( + create: (data) => _TestMyFavoritePage(), + parentPageType: _TestMyPage, + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + // Show First Page + await tester.pumpAndSettle(); + + expect(find.text('Test First Page'), findsOneWidget); + + // Show Second Page + await tester.tap(find.byKey(const ValueKey(_kTestGoSecondPageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Second Page'), findsOneWidget); + + // Show Home Tab and Show Title Page + await tester.tap(find.byKey(const ValueKey(_kTestGoHomePageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Page'), findsOneWidget); + + // Show Title Details Page + await tester + .tap(find.byKey(const ValueKey(_kTestGoTitleDetailsPageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Details Page'), findsOneWidget); + + // Show MyPage Tab and Show My Favorite Page + await tester.tap(find.byKey(const ValueKey(_kTestGoMyPageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test MyFavorite Page'), findsOneWidget); + + // GO Back Before Page + await tester + .tap(find.byKey(const ValueKey(_kTestStandardPageBackButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Details Page'), findsOneWidget); + + // GO Back Before Page + await tester + .tap(find.byKey(const ValueKey(_kTestStandardPageBackButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Page'), findsOneWidget); + + // GO Back Before Page + await tester + .tap(find.byKey(const ValueKey(_kTestStandardPageBackButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Second Page'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Test, Only Tab App", (WidgetTester tester) async { + // Standard Material Page Test + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + StandardPageFactory<_TestTitlePage, void>( + create: (data) => _TestTitlePage(), + parentPageType: _TestHomePage, + ), + StandardPageFactory<_TestHomePage, void>( + create: (data) => _TestHomePage(), + ), + StandardPageFactory<_TestTitleDetailsPage, void>( + create: (data) => _TestTitleDetailsPage(), + parentPageType: _TestHomePage, + ), + StandardPageFactory<_TestMyPage, void>( + create: (data) => _TestMyPage(), + ), + StandardPageFactory<_TestMyFavoritePage, void>( + create: (data) => _TestMyFavoritePage(), + parentPageType: _TestMyPage, + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + // Show Home Tab and Show Title Page + await tester.pumpAndSettle(); + + expect(find.text('Test Title Page'), findsOneWidget); + + // Show Title Details Page + await tester + .tap(find.byKey(const ValueKey(_kTestGoTitleDetailsPageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Details Page'), findsOneWidget); + + // Show MyPage Tab and Show My Favorite Page + await tester.tap(find.byKey(const ValueKey(_kTestGoMyPageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test MyFavorite Page'), findsOneWidget); + + // GO Back Before Page + await tester + .tap(find.byKey(const ValueKey(_kTestStandardPageBackButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Details Page'), findsOneWidget); + + // GO Back Before Page + await tester + .tap(find.byKey(const ValueKey(_kTestStandardPageBackButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Page'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Will Pop Page Test", (WidgetTester tester) async { + // Standard Material Page Test + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + willPopPage: (route, result) { + return true; + }, + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + StandardPageFactory<_TestFirstPage, void>( + create: (data) => _TestFirstPage(), + ), + StandardPageFactory<_TestSecondPage, void>( + create: (data) => _TestSecondPage(), + ), + StandardPageFactory<_TestHomePage, void>( + create: (data) => _TestHomePage(), + ), + StandardPageFactory<_TestTitlePage, void>( + create: (data) => _TestTitlePage(), + parentPageType: _TestHomePage, + ), + StandardPageFactory<_TestTitleDetailsPage, void>( + create: (data) => _TestTitleDetailsPage(), + parentPageType: _TestHomePage, + ), + StandardPageFactory<_TestMyPage, void>( + create: (data) => _TestMyPage(), + ), + StandardPageFactory<_TestMyFavoritePage, void>( + create: (data) => _TestMyFavoritePage(), + parentPageType: _TestMyPage, + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + // Show First Page + await tester.pumpAndSettle(); + + expect(find.text('Test First Page'), findsOneWidget); + + // Show Second Page + await tester.tap(find.byKey(const ValueKey(_kTestGoSecondPageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Second Page'), findsOneWidget); + + // Show Home Tab and Show Title Page + await tester.tap(find.byKey(const ValueKey(_kTestGoHomePageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Page'), findsOneWidget); + + // Show Title Details Page + await tester + .tap(find.byKey(const ValueKey(_kTestGoTitleDetailsPageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Details Page'), findsOneWidget); + + // GO Back Before Page + await tester + .tap(find.byKey(const ValueKey(_kTestStandardPageBackButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Details Page'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets( + "Standard Page Test, Child Page Instances (Go To Title Details Pages)", + (WidgetTester tester) async { + // Standard Material Page Test + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + StandardPageFactory<_TestFirstPage, void>( + create: (data) => _TestFirstPage(), + ), + StandardPageFactory<_TestHomePage, void>( + create: (data) => _TestHomePage(), + ), + StandardPageFactory<_TestTitlePage, void>( + create: (data) => _TestTitlePage(), + parentPageType: _TestHomePage, + ), + StandardPageFactory<_TestTitleDetailsPage, void>( + create: (data) => _TestTitleDetailsPage(), + parentPageType: _TestHomePage, + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + // Show First Page + await tester.pumpAndSettle(); + + expect(find.text('Test First Page'), findsOneWidget); + + // Show Title Details Page + await tester + .tap(find.byKey(const ValueKey(_kTestGoTitleDetailsPageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Details Page'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets( + "Standard Page Test, Child Page Instances (Child page is set first parent page is set after)", + (WidgetTester tester) async { + // Standard Material Page Test + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + StandardPageFactory<_TestFirstPage, void>( + create: (data) => _TestFirstPage(), + ), + StandardPageFactory<_TestTitlePage, void>( + create: (data) => _TestTitlePage(), + parentPageType: _TestHomePage, + ), + StandardPageFactory<_TestHomePage, void>( + create: (data) => _TestHomePage(), + ), + StandardPageFactory<_TestMyFavoritePage, void>( + create: (data) => _TestMyFavoritePage(), + parentPageType: _TestMyPage, + ), + StandardPageFactory<_TestMyPage, void>( + create: (data) => _TestMyPage(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + // Show First Page + await tester.pumpAndSettle(); + + expect(find.text('Test First Page'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(_kTestGoHomePageButton))); + + // Show Home Tab + await tester.pumpAndSettle(); + + expect(find.text('Test Title Page'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Test, Child Page Instances (Cupertino)", + (WidgetTester tester) async { + // Standard Cupertino Page Test + final App tApp = createApp( + environment: const Environment(), + appWidget: StandardCupertinoApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + // add all pages that inherit from StandardPage here + // not tabs page + StandardPageFactory<_TestCupertinoFirstPage, void>( + create: (data) => _TestCupertinoFirstPage(), + ), + // tabs page + // Home + StandardPageFactory<_TestCupertinoHomePage, void>( + create: (data) => _TestCupertinoHomePage(), + ), + StandardPageFactory<_TestCupertinoTitlePage, void>( + create: (data) => _TestCupertinoTitlePage(), + parentPageType: _TestCupertinoHomePage, + ), + // MyPage + StandardPageFactory<_TestCupertinoMyPage, void>( + create: (data) => _TestCupertinoMyPage(), + ), + StandardPageFactory<_TestCupertinoMyFavoritePage, void>( + create: (data) => _TestCupertinoMyFavoritePage(), + parentPageType: _TestCupertinoMyPage, + ), + ], + routableBuilder: (context, child) { + return CupertinoTheme( + data: CupertinoTheme.of(context) + .copyWith(brightness: Brightness.light), + child: child!, + ); + }, + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + // Show First Page + await tester.pumpAndSettle(); + + expect(find.text('Test First Page'), findsOneWidget); + + // Show Home Page + await tester.tap(find.byKey(const ValueKey(_kTestGoHomePageButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title Cupertino Page'), findsOneWidget); + + // Go Back First Page + await tester + .tap(find.byKey(const ValueKey(_kTestStandardPageBackButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test First Page'), findsOneWidget); + }); + + tApp.dispose(); + }); +} + +// Test Multi Tabs Material Page +class _TestFirstPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, 'Test First Page'), + ), + ), + body: ListView( + children: [ + TextButton( + key: const ValueKey(_kTestGoSecondPageButton), + child: const Text("Go to Second Page"), + onPressed: () { + context.go<_TestSecondPage, void>(null); + }, + ), + TextButton( + key: const ValueKey(_kTestGoHomePageButton), + child: const Text("Go to Test Home Page"), + onPressed: () { + context.go<_TestHomePage, void>(null); + }, + ), + TextButton( + key: const ValueKey(_kTestGoMyPageButton), + child: const Text("Go to Test My Page"), + onPressed: () { + context.go<_TestMyPage, void>(null); + }, + ), + TextButton( + key: const ValueKey(_kTestGoTitlePageButton), + child: const Text("Go to Title Page"), + onPressed: () { + context.go<_TestTitlePage, void>(null); + }, + ), + TextButton( + key: const ValueKey(_kTestGoTitleDetailsPageButton), + child: const Text("Go to Title Details Page"), + onPressed: () { + context.go<_TestTitleDetailsPage, void>(null); + }, + ), + TextButton( + key: const ValueKey(_kTestGoMyFavoritePageButton), + child: const Text("Go to MyFavorite Page"), + onPressed: () { + context.go<_TestMyFavoritePage, void>(null); + }, + ), + ], + ), + ); + } +} + +class _TestSecondPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, 'Test Second Page'), + ), + ), + body: ListView( + children: [ + TextButton( + key: const ValueKey(_kTestGoHomePageButton), + child: const Text("Go to Test Home Page"), + onPressed: () { + context.go<_TestHomePage, void>(null); + }, + ), + ], + ), + ); + } +} + +class _TestAppTab extends StatelessWidget { + const _TestAppTab({ + Key? key, + required this.body, + this.appBar, + }) : super(key: key); + + final Widget body; + final PreferredSizeWidget? appBar; + + @override + Widget build(BuildContext context) { + var tInterfacePage = + ModalRoute.of(context)!.settings as StandardPageInterface; + var tType = tInterfacePage.factoryObject.parentPageType; + int tIndex = 0; + if (tType == _TestHomePage) { + tIndex = 0; + } else if (tType == _TestMyPage) { + tIndex = 1; + } + + return Scaffold( + body: body, + appBar: appBar, + bottomNavigationBar: BottomNavigationBar( + onTap: (index) { + switch (index) { + case 0: + context.go<_TestHomePage, void>(null); + break; + case 1: + context.go<_TestMyPage, void>(null); + break; + default: + break; + } + }, + currentIndex: tIndex, + selectedItemColor: Colors.red, + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'home'), + BottomNavigationBarItem(icon: Icon(Icons.favorite), label: 'mypage'), + ], + type: BottomNavigationBarType.fixed, + ), + ); + } +} + +class _TestHomePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return childNavigator ?? const SizedBox.shrink(); + } +} + +class _TestTitlePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return _TestAppTab( + appBar: AppBar( + title: const Text("Test Title Page"), + automaticallyImplyLeading: false, + leading: const StandardPageBackButton( + key: ValueKey(_kTestStandardPageBackButton), + ), + ), + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text( + "Title Page", + ); + }, + ), + ), + TextButton( + key: const ValueKey(_kTestGoTitleDetailsPageButton), + child: const Text("Go to Test Title Details Page"), + onPressed: () { + context.go<_TestTitleDetailsPage, void>(null); + }, + ), + ], + ), + ); + } +} + +class _TestTitleDetailsPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return _TestAppTab( + appBar: AppBar( + title: const Text("Title Details Page"), + automaticallyImplyLeading: false, + leading: const StandardPageBackButton( + key: ValueKey(_kTestStandardPageBackButton), + ), + ), + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text( + "Test Title Details Page", + ); + }, + ), + ), + TextButton( + key: const ValueKey(_kTestGoMyPageButton), + child: const Text("Go to Test My Page"), + onPressed: () { + context.go<_TestMyPage, void>(null); + }, + ), + ], + ), + ); + } +} + +class _TestMyPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return childNavigator ?? const SizedBox.shrink(); + } +} + +class _TestMyFavoritePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return _TestAppTab( + appBar: AppBar( + title: const Text("MyFavorite Page"), + automaticallyImplyLeading: false, + leading: const StandardPageBackButton( + key: ValueKey(_kTestStandardPageBackButton), + ), + ), + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text( + "Test MyFavorite Page", + ); + }, + ), + ), + ], + ), + ); + } +} + +// Test Multi Tabs Cupertino Page +class _TestCupertinoFirstPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Test First Page'), + ), + child: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return Text( + l(context, 'plurals.test1', { + 'count': context + .select((v) => v.getInt('counter')) + }), + ); + }, + ), + ), + CupertinoButton( + key: const ValueKey(_kTestGoHomePageButton), + child: const Text("Go to Test Home Page"), + onPressed: () { + context.go<_TestCupertinoHomePage, void>(null); + }, + ), + CupertinoButton( + key: const ValueKey(_kTestGoTitlePageButton), + child: const Text("Go to Test Title Page"), + onPressed: () { + context.go<_TestCupertinoTitlePage, void>(null); + }, + ), + CupertinoButton( + key: const ValueKey(_kTestGoMyPageButton), + child: const Text("Go to Test My Page"), + onPressed: () { + context.go<_TestCupertinoMyPage, void>(null); + }, + ), + CupertinoButton( + key: const ValueKey(_kTestGoMyFavoritePageButton), + child: const Text("Go to Test MyFavorite Page"), + onPressed: () { + context.go<_TestCupertinoMyFavoritePage, void>(null); + }, + ), + ], + ), + ); + } +} + +class _TestCupertinoAppBar extends StatelessWidget { + const _TestCupertinoAppBar({ + Key? key, + required this.body, + this.appBar, + }) : super(key: key); + + final Widget body; + final Widget? appBar; + + @override + Widget build(BuildContext context) { + var tInterfacePage = + ModalRoute.of(context)!.settings as StandardPageInterface; + var tType = tInterfacePage.factoryObject.parentPageType; + int tIndex = 0; + if (tType == _TestCupertinoHomePage) { + tIndex = 0; + } else if (tType == _TestCupertinoMyPage) { + tIndex = 1; + } + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + leading: const StandardPageBackButton( + key: ValueKey(_kTestStandardPageBackButton), + ), + middle: tIndex == 0 ? const Text('Home Page') : const Text('My Page'), + ), + child: Column( + children: [ + Expanded( + child: body, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + CupertinoButton( + onPressed: () { + context.go<_TestCupertinoHomePage, void>(null); + }, + child: const Icon(CupertinoIcons.home), + ), + CupertinoButton( + onPressed: () { + context.go<_TestCupertinoMyFavoritePage, void>(null); + }, + child: const Icon(CupertinoIcons.heart), + ), + ], + ), + ], + ), + ); + } +} + +class _TestCupertinoHomePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return childNavigator ?? const SizedBox.shrink(); + } +} + +class _TestCupertinoTitlePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return _TestCupertinoAppBar( + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text( + "Test Title Cupertino Page", + ); + }, + ), + ), + ], + ), + ); + } +} + +class _TestCupertinoMyPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return childNavigator ?? const SizedBox.shrink(); + } +} + +class _TestCupertinoMyFavoritePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return _TestCupertinoAppBar( + appBar: AppBar( + title: const Text("Test Title Cupertino Page"), + automaticallyImplyLeading: false, + leading: const StandardPageBackButton( + key: ValueKey(_kTestStandardPageBackButton), + ), + ), + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text( + "Title Cupertino Page", + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_core/test/standard_app_widget_test.dart b/packages/patapata_core/test/standard_app_widget_test.dart new file mode 100644 index 0000000..96c3982 --- /dev/null +++ b/packages/patapata_core/test/standard_app_widget_test.dart @@ -0,0 +1,2167 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:provider/provider.dart'; +import 'utils/patapata_core_test_utils.dart'; +import 'utils/standard_app_widget_test_data.dart'; +import 'pages/startup_page.dart'; + +void main() { + testInitialize(); + + testWidgets("Standard Page Test, Pages added", (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + await tester.pumpAndSettle(); + + // update widget using provider + tProvider.changeTitle('Change Test Title'); + + tProvider.changePages([ + StandardPageFactory( + create: (data) => TestPageB(), + ), + StandardPageFactory( + create: (data) => TestPageA(), + ), + ]); + + await tester.pumpAndSettle(); + + // Keep currently active page. + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Test, Pages removed", (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + await tester.pumpAndSettle(); + + // update widget using provider + tProvider.changeTitle('Change Test Title'); + + tProvider.changePages([ + StandardPageFactory( + create: (data) => TestPageB(), + ), + ]); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Test, Pages removed in history", + (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + ), + StandardPageFactory( + create: (data) => TestPageC(), + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kGoPageDataButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title C'), findsOneWidget); + expect(find.text('Test Message C'), findsOneWidget); + + // update widget using provider + tProvider.changeTitle('Change Test Title'); + + tProvider.changePages([ + StandardPageFactory( + create: (data) => TestPageB(), + ), + StandardPageFactory( + create: (data) => TestPageC(), + ), + ]); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Test, Pages swapped", (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + await tester.pumpAndSettle(); + + // update widget using provider + tProvider.changeTitle('Change Test Title'); + + tProvider.changePages([ + StandardPageFactory( + create: (data) => TestPageB(), + ), + ]); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Test, Same pages (Not Update)", + (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + await tester.pumpAndSettle(); + + // If you do not transition to another page once, + // the array will not be entered in _pageInstances + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + // update widget using provider + tProvider.changeTitle('Change Test Title'); + + tProvider.changePages([ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + ), + ]); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Test, Reverse order pages (Update Pages)", + (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + // If you do not transition to another page once, + // the array will not be entered in _pageInstances + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + // update widget using provider + tProvider.changeTitle('Change Test Title'); + + tProvider.changePages([ + StandardPageFactory( + create: (data) => TestPageB(), + ), + StandardPageFactory( + create: (data) => TestPageA(), + ), + ]); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Test, Did update widgets", + (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + routableBuilder: tProvider.routableBuilder, + willPopPage: tProvider.willPopPage, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + // update widget using provider + tProvider.changeTitle('Change Test Title'); + tProvider.changePages( + [ + StandardPageFactory( + create: (data) => TestPageB(), + ), + ], + ); + tProvider.changeRoutableBuilder((context, child) { + return child!; + }); + tProvider.changeWillPopPage((route, result) { + return false; + }); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + }); + tApp.dispose(); + }); + + testWidgets("Standard Material App global Navigator Context Test", + (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Generate Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(StandardMaterialApp.globalNavigatorContext, isNotNull); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Cupertino Page Test", (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardCupertinoApp( + onGenerateTitle: (context) => 'Generate Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageF(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Message F'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Context Go Test", (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardCupertinoApp( + onGenerateTitle: (context) => 'Generate Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + pageDataWhenNull: () => TestPageData(id: 0, data: 'test page data'), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kGetPageFactoryButton))); + + await tester.pumpAndSettle(); + + // Check Page Factory + expect(pageFactory, isNotNull); + expect(pageFactory, isA>()); + + // Go To Test Page B + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kCheckNavigatorButton))); + + await tester.pumpAndSettle(); + + // Check Navigator Data + expect(navigatorState, isNotNull); + expect(navigatorState, isA()); + expect(navigatorKey, isNotNull); + expect(navigatorKey, isA>()); + expect(navigatorContext, isNotNull); + + // Back Test Page + await tester.tap(find.byKey(const ValueKey(kOnPopButton))); + + await tester.pumpAndSettle(); + + // Go To Test Page B (Router Delegate) + await tester.tap(find.byKey(const ValueKey(kGoToRouterDelegateButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page PageData Test", (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardCupertinoApp( + onGenerateTitle: (context) => 'Link Generate Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageC(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kGoPageDataButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Link Data is 10'), findsOneWidget); + + // Press the transition button twice to test onRefocus + await tester.tap(find.byKey(const ValueKey(kGoPageDataButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Link Data is 20'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Link Generator And Change PageData Test", + (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardCupertinoApp( + onGenerateTitle: (context) => 'Link Generate Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageC(), + links: { + r'testPageData/(\d+)': (match, uri) => TestPageData( + data: 'test page data', + id: int.parse(match.group(1)!), + ), + }, + linkGenerator: (pageData) => 'testPageData/${pageData.id}', + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kLinkButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title C'), findsOneWidget); + expect(find.text('Test Message C'), findsOneWidget); + expect(find.text('Test Link Data is 10'), findsOneWidget); + expect( + find.text('Test Generate Link is testPageData/10'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestChangePageDataButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Link Data is 30'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Change Listenable PageData Test", + (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardCupertinoApp( + onGenerateTitle: (context) => 'Change Listenable PageData Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageE(), + ), + ], + routableBuilder: (context, child) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => ChangeListenableBool(), + ), + ChangeNotifierProvider( + create: (context) => ChangeListenableNumber(), + ), + ], + child: child, + ); + }, + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kGoChangeDataButton))); + + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kChangePageDataButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Data Number : 100'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kChangePageDataButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Data Bool : true'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page PageData Test Before Ready", + (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardCupertinoApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageG(), + pageDataWhenNull: () => TestPageData( + data: 'test page data', + id: 76, + ), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('77 overridden test page data'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page On pop page", (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "Standard Page OnPopPage Test Title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + willPopPage: tProvider.willPopPage, + ); + }), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kOnPopButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + tProvider.changePages([ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + ), + ]); + tProvider.changeWillPopPage((route, result) => true); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + expect(find.text('Test Message'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kOnPopButton))); + + await tester.pumpAndSettle(); + + // here the provider made a change to return true + expect(find.text('Test Title B'), findsOneWidget); + expect(find.text('Test Message B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Custom Standard Page Test", (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardCupertinoApp( + onGenerateTitle: (context) => "Custom Standard Page Test", + pages: [ + StandardPageFactory( + create: (data) => TestPageD(), + pageBuilder: ( + child, + name, + pageData, + pageKey, + restorationId, + standardPageKey, + factoryObject, + ) => + StandardCustomPage( + name: "Test Custom Standard Page", + arguments: TestPageData(id: 10, data: 'test-custom-test-data'), + key: const ValueKey("test-custom-key"), + restorationId: "custom-restorationId", + standardPageKey: standardPageKey, + factoryObject: factoryObject, + opaque: false, + barrierDismissible: true, + barrierColor: Colors.blueAccent, + child: Column( + children: [ + Expanded(child: child), + const Text("add-custom-standard-page-widget"), + ], + ), + transitionDuration: const Duration(milliseconds: 500), + transitionBuilder: + (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: + Tween(begin: const Offset(0, 1), end: Offset.zero) + .animate(animation), + child: child, + ); + }, + ), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Page Id : 10'), findsOneWidget); + expect( + find.text('Test Page Data : test-custom-test-data'), findsOneWidget); + expect(find.text('Test Interface Name : Test Custom Standard Page'), + findsOneWidget); + expect(find.text('Test Interface RestorationId : custom-restorationId'), + findsOneWidget); + expect(find.text('add-custom-standard-page-widget'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page StandardPageNavigationMode Test", + (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + navigationMode: StandardPageNavigationMode.moveToTop, + ), + StandardPageFactory( + create: (data) => TestPageB(), + navigationMode: StandardPageNavigationMode.moveToTop, + ), + StandardPageFactory( + create: (data) => TestPageC(), + navigationMode: StandardPageNavigationMode.removeAll, + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + routableBuilder: tProvider.routableBuilder, + willPopPage: tProvider.willPopPage, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + // PageA -[moveToTop]-> PageB -> -[moveToTop]-> PageC -[removeAll] -> PageC + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title C'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kOnPopButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title C'), findsOneWidget); + }); + + tApp.dispose(); + + // PageA -[moveToTop]-> PageB -> -[moveToTop]-> PageC -[replace] -> PageA + tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + navigationMode: StandardPageNavigationMode.moveToTop, + ), + StandardPageFactory( + create: (data) => TestPageB(), + navigationMode: StandardPageNavigationMode.moveToTop, + ), + StandardPageFactory( + create: (data) => TestPageC(), + navigationMode: StandardPageNavigationMode.replace, + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + routableBuilder: tProvider.routableBuilder, + willPopPage: tProvider.willPopPage, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title C'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kOnPopButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + }); + + tApp.dispose(); + + // duplicate page key check (moveToTop) + LocalKey tLocalKey = const ValueKey("test-page-key"); + tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + navigationMode: StandardPageNavigationMode.moveToTop, + pageKey: (pageData) => tLocalKey, + ), + StandardPageFactory( + create: (data) => TestPageB(), + navigationMode: StandardPageNavigationMode.moveToTop, + ), + StandardPageFactory( + create: (data) => TestPageD(), + navigationMode: StandardPageNavigationMode.moveToTop, + pageKey: (pageData) => tLocalKey, + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + routableBuilder: tProvider.routableBuilder, + willPopPage: tProvider.willPopPage, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButtonSecond))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + }); + + tApp.dispose(); + + // duplicate page key check (replace) + tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + navigationMode: StandardPageNavigationMode.moveToTop, + pageKey: (pageData) => tLocalKey, + ), + StandardPageFactory( + create: (data) => TestPageB(), + navigationMode: StandardPageNavigationMode.moveToTop, + ), + StandardPageFactory( + create: (data) => TestPageD(), + navigationMode: StandardPageNavigationMode.replace, + pageKey: (pageData) => tLocalKey, + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + routableBuilder: tProvider.routableBuilder, + willPopPage: tProvider.willPopPage, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButtonSecond))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + }); + + tApp.dispose(); + + // duplicate page key check (removeAbove) + tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + navigationMode: StandardPageNavigationMode.moveToTop, + pageKey: (pageData) => tLocalKey, + ), + StandardPageFactory( + create: (data) => TestPageB(), + navigationMode: StandardPageNavigationMode.moveToTop, + ), + StandardPageFactory( + create: (data) => TestPageD(), + navigationMode: StandardPageNavigationMode.removeAbove, + pageKey: (pageData) => tLocalKey, + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + routableBuilder: tProvider.routableBuilder, + willPopPage: tProvider.willPopPage, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kTestButtonSecond))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Keep History Test", (WidgetTester tester) async { + late TestChangeNotifierData tProvider; + final App tApp = createApp( + appWidget: ChangeNotifierProvider( + create: (context) => TestChangeNotifierData( + title: "title", + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + keepHistory: false, + ), + StandardPageFactory( + create: (data) => TestPageB(), + keepHistory: false, + ), + ], + ), + child: Builder(builder: (context) { + tProvider = context.watch(); + + return StandardMaterialApp( + onGenerateTitle: (context) => tProvider.title, + pages: tProvider.pages, + routableBuilder: tProvider.routableBuilder, + willPopPage: tProvider.willPopPage, + ); + }), + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey(kOnPopButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Group And GroupRoot Test", + (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Group And GroupRoot Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + group: 'test-group', + groupRoot: true, + ), + StandardPageFactory( + create: (data) => TestPageC(), + links: { + r'testPageData/(\d+)': (match, uri) => TestPageData( + data: 'test page data', + id: int.parse(match.group(1)!), + ), + }, + linkGenerator: (pageData) => 'testPageData/${pageData.id}', + group: 'test-group', + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + // Show Link Page + await tester.tap(find.byKey(const ValueKey(kLinkButton))); + + await tester.pumpAndSettle(); + + // Go Back TestPageB + await tester.tap(find.byKey(const ValueKey(kOnPopButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Analytics Event Test", + (WidgetTester tester) async { + testAnalytics = testAnalytics = TestAnalyticsEvent( + name: "test analytics", + data: {"data": 99999}, + ); + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Analytics Event Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Analytics Event Data : {data: 99999}'), findsOneWidget); + + testAnalytics = null; + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Remove Route Test", (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Analytics Event Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + // Go To Next Test A2 Page + await tester.tap(find.byKey(const ValueKey(kTestButton))); + + await tester.pumpAndSettle(); + + // Remove Route Page + await tester.tap(find.byKey(const ValueKey(kRemoveRouteButton))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page App LinkHandler", (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'App LinkHander Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + ); + + // add link handler + var tPlugin = tApp.getPlugin(); + tPlugin?.addLinkHandler(onLink); + + await tester.pumpAndSettle(); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(isLinkHander, isTrue); + expect(linkHanderUri, isNotNull); + expect(linkHanderUri!.path, equals('/')); + + // remove link handler + tPlugin?.removeLinkHandler(onLink); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Context StandardAppRouterContext Test", + (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StandardAppRouterContext Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester + .tap(find.byKey(const ValueKey(kGetStandardAppRouterContext))); + + await tester.pumpAndSettle(); + + expect(buildContext, isNotNull); + expect(buildContext!.router, isNotNull); + expect(buildContext!.getPageFactory(), + isA>()); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page ProcessInitialRoute Test", + (WidgetTester tester) async { + Plugin tPlugin = TestDataPlugin(); + final App tApp = createApp( + plugins: [ + tPlugin, + ], + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'ProcessInitialRoute Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kProcessInitialRouteButton))); + + await tester.pumpAndSettle(); + + final tStandardRouteData = + await tApp.getPlugin()?.getInitialRouteData(); + + await tester.pumpAndSettle(); + + final tTestPageData = tStandardRouteData?.pageData as TestPageData; + + expect(tTestPageData, isNotNull); + expect(tTestPageData.id, 9999); + expect(tTestPageData.data, 'test plugin data'); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page ProcessInitialRoute Null Data Test", + (WidgetTester tester) async { + Plugin tPlugin = TestNullDataPlugin(); + final App tApp = createApp( + plugins: [ + tPlugin, + ], + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'ProcessInitialRoute Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey(kProcessInitialRouteButton))); + + await tester.pumpAndSettle(); + + final tStandardRouteData = + await tApp.getPlugin()!.getInitialRouteData(); + + await tester.pumpAndSettle(); + + expect(tStandardRouteData, isNull); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Default Route Test", (WidgetTester tester) async { + tester.binding.platformDispatcher.defaultRouteNameTestValue = '/testA'; + + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageF(), + ), + StandardPageFactory( + create: (data) => TestPageH(), + links: { + r'testA': (match, uri) => TestPageData( + id: 9999, + data: 'test A', + ), + }, + linkGenerator: (pageData) => r'testA', + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Message F'), findsOneWidget); + + tApp.getPlugin()!.delegate!.processInitialRoute(); + + await tester.pumpAndSettle(); + + expect(find.text('9999 test A'), findsOneWidget); + }); + + tApp.dispose(); + tester.binding.platformDispatcher.clearDefaultRouteNameTestValue(); + }); + + testWidgets("Standard Page Default Route Bad Links Test", + (WidgetTester tester) async { + tester.binding.platformDispatcher.defaultRouteNameTestValue = '/testA'; + + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageF(), + ), + StandardPageFactory( + create: (data) => TestPageH(), + links: { + r'testA': (match, uri) => throw Exception('Bad Links Handler'), + }, + linkGenerator: (pageData) => r'testA', + ), + ], + ), + ); + + tApp.run(); + + bool tGotIt = false; + + final tSub = Logger.root.onRecord.listen((record) { + if (record.level == Level.INFO && + record.message == 'Exception during links callback' && + record.error is Exception && + record.error.toString() == 'Exception: Bad Links Handler') { + tGotIt = true; + } + }); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Message F'), findsOneWidget); + + tApp.getPlugin()!.delegate!.processInitialRoute(); + + await tester.pumpAndSettle(); + + expect(tGotIt, isTrue); + }); + + tApp.dispose(); + tester.binding.platformDispatcher.clearDefaultRouteNameTestValue(); + tSub.cancel(); + }); + + testWidgets("Standard Page OS Extra Route Test", (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageF(), + ), + StandardPageFactory( + create: (data) => TestPageH(), + links: { + r'testA': (match, uri) => TestPageData( + id: 9999, + data: 'test A', + ), + }, + linkGenerator: (pageData) => r'testA', + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Message F'), findsOneWidget); + + tApp.getPlugin()!.delegate!.processInitialRoute(); + + await tester.pumpAndSettle(); + + expect(find.text('Test Message F'), findsOneWidget); + + // https://github.com/flutter/flutter/blob/master/packages/flutter/test/widgets/router_test.dart#L815C14-L815C14 + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.navigation.name, + const JSONMethodCodec().encodeMethodCall( + const MethodCall('pushRoute', '/testA'), + ), + (_) {}, + ); + await tester.pump(); + + await tester.pumpAndSettle(); + + expect(find.text('9999 test A'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Plugin Route Test", (WidgetTester tester) async { + final App tApp = createApp( + plugins: [ + TestDataParseRouteInformationPlugin(), + ], + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageF(), + ), + StandardPageFactory( + create: (data) => TestPageH(), + ) + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Message F'), findsOneWidget); + + tApp.getPlugin()!.delegate!.processInitialRoute(); + + await tester.pumpAndSettle(); + + expect(find.text('70 parsed route'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("Standard Page Bad Link Handler Test", + (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageF(), + ), + StandardPageFactory( + create: (data) => TestPageH(), + ) + ], + ), + ); + + tApp.run(); + + bool tGotIt = false; + + final tSub = Logger.root.onRecord.listen((record) { + if (record.level == Level.SEVERE && + record.message == 'Error while handling link' && + record.error is Exception && + record.error.toString() == 'Exception: Bad Link') { + tGotIt = true; + } + }); + + tApp.getPlugin()!.addLinkHandler((link) { + throw Exception('Bad Link'); + }); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(find.text('Test Message F'), findsOneWidget); + + tApp.getPlugin()!.delegate!.processInitialRoute(); + + expect(tGotIt, isTrue); + + await tester.pumpAndSettle(); + }); + + tApp.dispose(); + tSub.cancel(); + }); + + testWidgets("Standard Page App Route Test", (WidgetTester tester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Group And Route Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageC(), + links: { + r'testPageData/(\d+)': (match, uri) => TestPageData( + data: 'test page data', + id: int.parse(match.group(1)!), + ), + }, + linkGenerator: (pageData) => 'testPageData/${pageData.id}', + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + await tester + .tap(find.byKey(const ValueKey(kGetStandardAppPluginGenerateLink))); + + await tester.pumpAndSettle(); + + expect( + find.text('generateLink testText testPageData/9999'), findsOneWidget); + + // Show Link Page + await tester.tap(find.byKey(const ValueKey(kGetStandardAppPluginRoute))); + + await tester.pumpAndSettle(); + + expect(find.text('Test Title C'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("StandardAppPlugin with StartupNavigatorMixin Test", + (WidgetTester tester) async { + tester.binding.platformDispatcher.defaultRouteNameTestValue = '/testA'; + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupNavigatorMixin Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StandardPageFactory( + create: (data) => TestPageH(), + links: { + r'testA': (match, uri) => TestPageData( + id: 9999, + data: 'test A', + ), + }, + linkGenerator: (pageData) => r'testA', + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + final tCompleter = Completer(); + + tApp.getPlugin()?.startupNavigateToPage(StartupPageA, + (result) { + tCompleter.complete(result as bool); + }); + + await tester.pumpAndSettle(); + + await tester.tap(find.text('Complete')); + + final tResult = await tCompleter.future; + + expect(tResult, true); + + tApp.getPlugin()?.startupProcessInitialRoute(); + + await tester.pumpAndSettle(); + + expect(find.text('9999 test A'), findsOneWidget); + + tApp.getPlugin()?.startupOnReset(); + + await tester.pumpAndSettle(); + + expect(find.text('SplashPage'), findsOneWidget); + }); + + tApp.dispose(); + tester.binding.platformDispatcher.clearDefaultRouteNameTestValue(); + }); + + // Test the results system + testWidgets( + 'You can navigate to a StandardPageWithResult and get the result values', + (widgetTester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageContextGoWithResult(), + ), + StandardPageWithResultFactory( + create: (data) => TestPageWithResult(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await widgetTester.pumpAndSettle(); + + // Navigate to the page with results + await widgetTester.tap(find.byKey(const ValueKey(kTestButton))); + + await widgetTester.pumpAndSettle(); + + expect(find.text('TestPageWithResult'), findsOneWidget); + + // Tap the button to return the result + await widgetTester.tap(find.byKey(const ValueKey(kTestButton))); + + await widgetTester.pumpAndSettle(); + + // Check that the result is correct + expect(find.text('TestPageContextGoWithResult'), findsOneWidget); + expect(find.text('pageResult'), findsOneWidget); + + // Navigate to the page with results + await widgetTester.tap(find.byKey(const ValueKey(kTestButton))); + + await widgetTester.pumpAndSettle(); + + expect(find.text('TestPageWithResult'), findsOneWidget); + + // Tap the button to return the result + await widgetTester.tap(find.byKey(const ValueKey(kTestButtonSecond))); + + await widgetTester.pumpAndSettle(); + + // Check that the result is correct + expect(find.text('TestPageContextGoWithResult'), findsOneWidget); + expect(find.text('popResult'), findsOneWidget); + }); + + tApp.dispose(); + }); + + // Test the StandardAppApp extension on App + testWidgets('StandardAppApp extension on App', (widgetTester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageContextGoWithResult(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + ), + StandardPageWithResultFactory( + create: (data) => TestPageWithResult(), + links: { + r'results': (match, uri) {}, + }, + linkGenerator: (pageData) => 'results', + ), + StandardPageFactory( + create: (data) => TestPageB(), + links: { + r'b': (match, uri) {}, + }, + linkGenerator: (pageData) => 'b', + ), + StandardPageFactory( + create: (data) => TestPageRemoveRoute(), + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await widgetTester.pumpAndSettle(); + + final tApp = getApp(); + + expect(tApp.navigator, isNotNull); + expect(tApp.navigatorContext, isNotNull); + expect(find.text('TestPageContextGoWithResult'), findsOneWidget); + + String? tResult; + + tApp.goWithResult(null).then((value) { + tResult = value; + }); + + await widgetTester.pumpAndSettle(); + + expect(find.text('TestPageWithResult'), findsOneWidget); + + // Tap the button to return the result + await widgetTester.tap(find.byKey(const ValueKey(kTestButton))); + + await widgetTester.pumpAndSettle(); + + // Check that the result is correct + expect(find.text('TestPageContextGoWithResult'), findsOneWidget); + expect(tResult, 'pageResult'); + + tApp.go(null); + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + + tApp.route('results'); + await widgetTester.pumpAndSettle(); + + expect(find.text('TestPageWithResult'), findsOneWidget); + + tApp.removeRoute(); + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + + expect( + tApp.generateLinkWithResult(null), + 'results'); + expect(tApp.generateLink(null), 'b'); + + tApp.go(null); + await widgetTester.pumpAndSettle(); + + expect(find.text('TestPageRemoveRoute'), findsOneWidget); + + await widgetTester.tap(find.byKey(const ValueKey(kTestButton))); + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets('You can access navigation APIs from routableBuilder', + (widgetTester) async { + final tCompleter = Completer(); + bool tInited = false; + BuildContext? tContext; + + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageRemoveRoute(), + ), + ], + routableBuilder: (context, child) { + tContext = context; + + if (!tInited) { + tInited = true; + + Future.microtask(() async { + tContext!.go(null); + + await widgetTester.pumpAndSettle(); + + expect(find.text('TestPageRemoveRoute'), findsOneWidget); + + tCompleter.complete(); + }); + } + + return child!; + }, + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await widgetTester.pumpAndSettle(); + await tCompleter.future; + }); + + tApp.dispose(); + }); + + testWidgets('You can wrap the contents of a StandardPage with a Plugin', + (widgetTester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + ], + ), + plugins: [ + StandardPagePluginMixin.inline( + buildPage: (context, child) => Stack( + children: [ + child, + const Text('StandardPagePluginMixin'), + ], + ), + ), + ], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await widgetTester.pumpAndSettle(); + + expect(find.text('StandardPagePluginMixin'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets('You can override the initial route with a Plugin', + (widgetTester) async { + final tPageBFactory = StandardPageFactory( + create: (data) => TestPageB(), + ); + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + tPageBFactory, + ], + ), + plugins: [ + StandardAppRoutePluginMixin.inline( + getInitialRouteData: () => Future.value( + StandardRouteData( + factory: tPageBFactory, + pageData: null, + ), + ), + ), + ], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await widgetTester.pumpAndSettle(); + + tApp.standardAppPlugin.delegate!.processInitialRoute(); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets('You can override route parsing with a Plugin', + (widgetTester) async { + final tPageBFactory = StandardPageFactory( + create: (data) => TestPageB(), + ); + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + tPageBFactory, + ], + ), + plugins: [ + StandardAppRoutePluginMixin.inline( + parseRouteInformation: (routeInformation) => Future.value( + StandardRouteData( + factory: tPageBFactory, + pageData: null, + ), + ), + ), + ], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await widgetTester.pumpAndSettle(); + + tApp.standardAppPlugin.delegate!.processInitialRoute(); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets('You can transform a route with a Plugin', (widgetTester) async { + final App tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'Test Title', + pages: [ + StandardPageFactory( + create: (data) => TestPageA(), + ), + StandardPageFactory( + create: (data) => TestPageB(), + links: { + r'testB': (match, uri) {}, + }, + linkGenerator: (pageData) => 'testB', + ), + ], + ), + plugins: [ + StandardAppRoutePluginMixin.inline( + transformRouteInformation: (routeInformation) => Future.value( + RouteInformation(uri: Uri(path: '/testB')), + ), + ), + ], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await widgetTester.pumpAndSettle(); + + tApp.standardAppPlugin.delegate!.processInitialRoute(); + + await widgetTester.pumpAndSettle(); + + expect(find.text('Test Title B'), findsOneWidget); + }); + + tApp.dispose(); + }); +} diff --git a/packages/patapata_core/test/startup_test.dart b/packages/patapata_core/test/startup_test.dart new file mode 100644 index 0000000..e71775d --- /dev/null +++ b/packages/patapata_core/test/startup_test.dart @@ -0,0 +1,1233 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'utils/patapata_core_test_utils.dart'; +import 'pages/startup_page.dart'; + +void main() { + test('StartupNavigatorMixin blank test', () async { + final tPlugin = TestBlankNavigatorPlugin(); + + // No exceptions thrown + tPlugin.startupNavigateToPage(Object, (result) {}); + tPlugin.startupOnReset(); + tPlugin.startupProcessInitialRoute(); + }); + + test('StartupNavigatorMixin test', () async { + final tPlugin = TestNavigatorPlugin(); + + bool tNavigateResult = false; + tPlugin.startupNavigateToPage(StartupPageA, (result) { + tNavigateResult = result as bool; + }); + tPlugin.startupOnReset(); + tPlugin.startupProcessInitialRoute(); + + expect(tPlugin.startupNavigateToPageCalled, true); + expect(tPlugin.navigatedPage, StartupPageA); + expect(tPlugin.startupProcessInitialRouteCalled, true); + expect(tPlugin.startupOnResetCalled, true); + expect(tNavigateResult, true); + }); + + testWidgets("StartupSequence test", (WidgetTester tester) async { + StateA? tStateA; + StateB? tStateB; + StateC? tStateC; + StateD? tStateD; + + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StartupPageFactory( + create: (data) => StartupPageB(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + waitSplashScreenDuration: const Duration(milliseconds: 2000), + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => tStateA = StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => tStateB = StateB(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => tStateC = StateC(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => tStateD = StateD(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + expect(tStateA?.processed, true); + expect(tStateB?.processed, null); + expect(tStateC?.processed, null); + expect(tStateD?.processed, null); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('SplashPage'), findsOneWidget); + expect(tStateB?.processed, true); + expect(tStateB?.navigateResult, false); + expect(tStateB?.pageResult, false); + expect(tStateC?.processed, null); + expect(tStateD?.processed, null); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsOneWidget); + expect(tStateB?.navigateResult, false); + expect(tStateB?.pageResult, false); + expect(tStateC?.processed, null); + expect(tStateD?.processed, null); + + await tester.tap(find.text('Complete')); + expect(tStateB?.pageResult, true); + + await tester.pumpAndSettle(); + expect(find.text('StartupPageB'), findsOneWidget); + expect(tStateB?.navigateResult, true); + expect(tStateC?.processed, true); + expect(tStateC?.navigateResult, false); + expect(tStateC?.pageResult, false); + + await tester.tap(find.text('Complete')); + expect(tStateC?.pageResult, true); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('HomePage'), findsOneWidget); + expect(tStateD?.processed, true); + expect(tStateC?.navigateResult, true); + }); + + tApp.dispose(); + }); + + testWidgets("waitForComplete and onSuccess test", + (WidgetTester tester) async { + bool tOnSuccess = false; + + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StateA(startupSequence), + [], + ), + ], + onSuccess: () => tOnSuccess = true, + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + final tWaitForComplete = Completer(); + tApp.startupSequence! + .waitForComplete() + .then((value) => tWaitForComplete.complete()); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + + await tWaitForComplete.future; + + await tester.pumpAndSettle(); + expect(find.text('HomePage'), findsOneWidget); + + // Already Complete. To achieve 100% test coverage. + expect(tApp.startupSequence?.waitForComplete().runtimeType, + SynchronousFuture); + + expect(tOnSuccess, true); + }); + + tApp.dispose(); + }); + + testWidgets("resetMachine test", (WidgetTester tester) async { + StateA? tStateA; + StateB? tStateB; + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => tStateA = StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => tStateB = StateB(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsOneWidget); + expect(tStateA?.processed, true); + expect(tStateB?.processed, true); + expect(tStateB?.navigateResult, false); + expect(tStateB?.pageResult, false); + + final tOldStateA = tStateA; + final tOldStateB = tStateB; + await tester.tap(find.text('Reset')); + + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + expect(tStateA.hashCode, isNot(tOldStateA.hashCode)); + expect(tOldStateB?.navigateResult, false); + expect(tOldStateB?.pageResult, false); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsOneWidget); + expect(tStateA?.processed, true); + expect(tStateB?.processed, true); + expect(tStateB?.navigateResult, false); + expect(tStateB?.pageResult, false); + expect(tStateB.hashCode, isNot(tOldStateB.hashCode)); + + await tester.tap(find.text('Complete')); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('HomePage'), findsOneWidget); + + expect(tStateB?.navigateResult, true); + expect(tStateB?.pageResult, true); + }); + + tApp.dispose(); + }); + + testWidgets("error from logger test", (WidgetTester tester) async { + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StateXB(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StateC(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + Object? tError; + StreamSubscription tLogLester = + tApp.log.reports.listen((event) { + tError = event.error; + }); + + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsNothing); + + expect(tApp.startupSequence?.error?.error, 'StateXB'); + expect(tError, 'StateXB'); + + tLogLester.cancel(); + }); + + tApp.dispose(); + }); + + testWidgets("waitForComplete cacheError test", (WidgetTester tester) async { + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StateXB(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StateC(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + final tWaitForComplete = Completer(); + tApp.startupSequence!.waitForComplete().catchError((error, stackTrace) { + tWaitForComplete.completeError(error, stackTrace); + }); + + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsNothing); + + expect( + () => tWaitForComplete.future, + throwsA('StateXB'), + ); + + expect(tApp.startupSequence?.error?.error, 'StateXB'); + + // Already Complete. To achieve 100% test coverage. + expect( + () => tApp.startupSequence?.waitForComplete(), + throwsA('StateXB'), + ); + }); + + tApp.dispose(); + }); + + testWidgets("onError test", (WidgetTester tester) async { + Object? tError; + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StateXB(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StateC(startupSequence), + [], + ), + ], + onError: (error, stackTrace) { + tError = error; + }, + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + Object? tErrorFromLogger; + StreamSubscription tLogLester = + tApp.log.reports.listen((event) { + tErrorFromLogger = event.error; + }); + + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsNothing); + + expect(tError, 'StateXB'); + expect(tErrorFromLogger, null); + + tLogLester.cancel(); + }); + + tApp.dispose(); + }); + + testWidgets("error resetMachine test", (WidgetTester tester) async { + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StateXB(startupSequence), + [], + ), + ], + onError: (e, stackTrace) { + // To avoid extra logging. Already tested. + }, + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + + final tError = tApp.startupSequence!.error!; + expect(tError.error, 'StateXB'); + + tApp.startupSequence!.resetMachine(); + + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + expect(tApp.startupSequence!.error, null); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + + expect(tApp.startupSequence!.error!.error, 'StateXB'); + expect(tApp.startupSequence!.error, isNot(tError)); + }); + + tApp.dispose(); + }); + + testWidgets("navigateToPage throw LogicStateNotCurrent test", + (WidgetTester tester) async { + StateB? tStateB; + + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StartupPageFactory( + create: (data) => StartupPageB(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => tStateB = StateB(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StateC(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + await tester.tap(find.text('Complete')); + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + + expect( + () => tStateB!.navigateToPage(StartupPageA, (result) {}), + throwsA(isA()), + ); + }); + + tApp.dispose(); + }); + + testWidgets("resetMachine with splash timer active", + (WidgetTester tester) async { + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + waitSplashScreenDuration: const Duration(milliseconds: 2000), + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StateA(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + + tApp.startupSequence!.resetMachine(); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('HomePage'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("State is reset during process", (WidgetTester tester) async { + StateDelayed2000ms? tState; + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + waitSplashScreenDuration: const Duration(milliseconds: 16), + startupStateFactories: [ + StartupStateFactory( + (startupSequence) { + final tS = StateDelayed2000ms(startupSequence); + tState ??= tS; + return tS; + }, + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(const Duration(milliseconds: 20)); + + tApp.startupSequence!.resetMachine(); + + expect(() => tState!.onComplete, throwsA(isA())); + + await tester.pumpAndSettle(const Duration(milliseconds: 2000)); + expect(find.text('HomePage'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("complete before process completion", + (WidgetTester tester) async { + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StateCompleteBeforeProcess(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(const Duration(milliseconds: 3000)); + expect(find.text('HomePage'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("completeError before process completion", + (WidgetTester tester) async { + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => + StateCompleteErrorBeforeProcess(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(const Duration(milliseconds: 3000)); + + expect(() => tApp.startupSequence!.waitForComplete(), + throwsA(isA())); + expect(tApp.startupSequence!.error?.error, + 'StateCompleteErrorBeforeProcess'); + }); + + tApp.dispose(); + }); + + testWidgets("back state test", (WidgetTester tester) async { + StateC? tFirstStateC; + StartupState? tCurrentState; + + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StartupPageFactory( + create: (data) => StartupPageB(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => tCurrentState = StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => tCurrentState = StateB(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) { + final tState = StateC(startupSequence); + tFirstStateC ??= tState; + return tCurrentState = tState; + }, + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => tCurrentState = StateD(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsOneWidget); + await tester.tap(find.text('Complete')); + + await tester.pumpAndSettle(); + expect(find.text('StartupPageB'), findsOneWidget); + expect(tCurrentState, isA()); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('StartupPageA'), findsOneWidget); + expect(tCurrentState, isA()); + expect(tFirstStateC?.processed, true); + expect(tFirstStateC?.navigateResult, false); + expect(tFirstStateC?.pageResult, false); + + await tester.tap(find.text('Complete')); + + await tester.pumpAndSettle(); + expect(find.text('StartupPageB'), findsOneWidget); + expect(tCurrentState, isA()); + expect(tCurrentState, isNot(tFirstStateC)); + + await tester.tap(find.text('Complete')); + expect(tCurrentState, isA()); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('HomePage'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("back state test. remove route", (WidgetTester tester) async { + StateB? tFirstStateB; + StartupState? tCurrentState; + + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => tCurrentState = StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) { + final tState = StateB(startupSequence); + tFirstStateB ??= tState; + return tCurrentState = tState; + }, + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => + tCurrentState = StateDelayed2000ms(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsOneWidget); + expect(tCurrentState, isA()); + + await tester.tap(find.text('PushModalA')); + await tester.pumpAndSettle(); + expect(find.text('StartupModalPageA'), findsOneWidget); + expect(tCurrentState, isA()); + expect(tFirstStateB?.processed, true); + expect(tFirstStateB?.navigateResult, false); + expect(tFirstStateB?.pageResult, false); + + await tester.tap(find.text('CompleteAndPushModaB')); + await tester.pumpAndSettle(); + expect(tCurrentState, isA()); + expect(tFirstStateB?.navigateResult, true); + expect(tFirstStateB?.pageResult, true); + expect(find.text('StartupModalPageB'), findsOneWidget); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(find.text('StartupModalPageA'), findsOneWidget); + expect(tCurrentState, isA()); + expect(tCurrentState, isNot(tFirstStateB)); + + final tTemp = tCurrentState; + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(find.text('StartupPageA'), findsOneWidget); + expect(tCurrentState, equals(tTemp)); + expect(tCurrentState, isNot(tFirstStateB)); + + await tester.tap(find.text('Complete')); + expect(tCurrentState, isA()); + + await tester.pumpAndSettle(const Duration(milliseconds: 2000)); + expect(find.text('HomePage'), findsOneWidget); + }); + + tApp.dispose(); + }); + + testWidgets("back state test. replace route", (WidgetTester tester) async { + StateB? tFirstStateB; + StartupState? tCurrentState; + + final tApp = createApp( + appWidget: StandardMaterialApp( + onGenerateTitle: (context) => 'StartupSequence Test Title', + pages: [ + SplashPageFactory( + create: (data) => SplashPage(), + ), + StartupPageFactory( + create: (data) => StartupPageA(), + ), + StandardPageFactory( + create: (data) => TestHomePage(), + links: { + r'': (match, uri) => TestHomePage(), + }, + linkGenerator: (pageData) => r'', + ), + ], + ), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => tCurrentState = StateA(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) { + final tState = StateB(startupSequence); + tFirstStateB ??= tState; + return tCurrentState = tState; + }, + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => + tCurrentState = StateDelayed2000ms(startupSequence), + [], + ), + ], + ), + ); + + tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + expect(find.text('SplashPage'), findsOneWidget); + + await tester.pumpAndSettle(const Duration(milliseconds: 1000)); + expect(find.text('StartupPageA'), findsOneWidget); + expect(tCurrentState, isA()); + + await tester.tap(find.text('PushModalA')); + await tester.pumpAndSettle(); + expect(find.text('StartupModalPageA'), findsOneWidget); + expect(tCurrentState, isA()); + expect(tFirstStateB?.processed, true); + expect(tFirstStateB?.navigateResult, false); + expect(tFirstStateB?.pageResult, false); + + await tester.tap(find.text('ReplaceAtoB')); + await tester.pumpAndSettle(); + expect(find.text('StartupModalPageB'), findsOneWidget); + + await tester.tap(find.text('CompleteAndPushModaC')); + await tester.pumpAndSettle(); + expect(tCurrentState, isA()); + expect(tFirstStateB?.navigateResult, true); + expect(tFirstStateB?.pageResult, true); + expect(find.text('StartupModalPageC'), findsOneWidget); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(find.text('StartupModalPageB'), findsOneWidget); + expect(tCurrentState, isA()); + expect(tCurrentState, isNot(tFirstStateB)); + + final tTemp = tCurrentState; + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(find.text('StartupPageA'), findsOneWidget); + expect(tCurrentState, equals(tTemp)); + expect(tCurrentState, isNot(tFirstStateB)); + + await tester.tap(find.text('Complete')); + expect(tCurrentState, isA()); + + await tester.pumpAndSettle(const Duration(milliseconds: 2000)); + expect(find.text('HomePage'), findsOneWidget); + }); + + tApp.dispose(); + }); +} + +class StateA extends StartupState { + StateA(super.startupSequence); + + bool processed = false; + + @override + Future process(Object? data) async { + processed = true; + return await Future.delayed(const Duration(milliseconds: 500)); + } +} + +class StateB extends StartupState { + StateB(super.startupSequence); + + bool processed = false; + bool pageResult = false; + bool navigateResult = false; + + @override + Future process(Object? data) async { + processed = true; + navigateResult = await navigateToPage(StartupPageA, (result) { + pageResult = result as bool; + }); + } +} + +class StateXB extends StartupState { + StateXB(super.startupSequence); + + bool processed = false; + + @override + Future process(Object? data) async { + processed = true; + throw 'StateXB'; + } +} + +class StateC extends StartupState { + StateC(super.startupSequence); + + bool processed = false; + bool pageResult = false; + bool navigateResult = false; + + @override + Future process(Object? data) async { + processed = true; + navigateResult = await navigateToPage(StartupPageB, (result) { + pageResult = result as bool; + }); + } +} + +class StateD extends StartupState { + StateD(super.startupSequence); + + bool processed = false; + + @override + Future process(Object? data) async { + processed = true; + } +} + +class StateDelayed2000ms extends StartupState { + StateDelayed2000ms(super.startupSequence); + + bool processed = false; + + @override + Future process(Object? data) async { + processed = true; + return await Future.delayed(const Duration(milliseconds: 2000)); + } +} + +class StateCompleteBeforeProcess extends StartupState { + StateCompleteBeforeProcess(super.startupSequence); + + bool processed = false; + + @override + Future process(Object? data) async { + processed = true; + complete(); + return await Future.delayed(const Duration(milliseconds: 2000)); + } +} + +class StateCompleteErrorBeforeProcess extends StartupState { + StateCompleteErrorBeforeProcess(super.startupSequence); + + bool processed = false; + + @override + Future process(Object? data) async { + processed = true; + completeError('StateCompleteErrorBeforeProcess'); + return await Future.delayed(const Duration(milliseconds: 2000)); + } +} + +class StateResetMachine extends StartupState { + StateResetMachine(super.startupSequence); + + bool processed = false; + + @override + Future process(Object? data) async { + processed = true; + startupSequence.resetMachine(); + } +} + +class TestBlankNavigatorPlugin extends Plugin with StartupNavigatorMixin {} + +class TestNavigatorPlugin extends Plugin with StartupNavigatorMixin { + Object? navigatedPage; + bool startupNavigateToPageCalled = false; + bool startupProcessInitialRouteCalled = false; + bool startupOnResetCalled = false; + + @override + void startupNavigateToPage(Object page, StartupPageCompleter completer) { + navigatedPage = page; + startupNavigateToPageCalled = true; + completer(true); + } + + @override + void startupProcessInitialRoute() { + startupProcessInitialRouteCalled = true; + } + + @override + startupOnReset() { + startupOnResetCalled = true; + } +} diff --git a/packages/patapata_core/test/user_test.dart b/packages/patapata_core/test/user_test.dart new file mode 100644 index 0000000..b31ca50 --- /dev/null +++ b/packages/patapata_core/test/user_test.dart @@ -0,0 +1,314 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; + +import 'utils/patapata_core_test_utils.dart'; + +class TestPluginA extends Plugin {} + +class TestPluginB extends Plugin {} + +class FetchExceptionRemoteConfig extends RemoteConfig { + @override + Future fetch( + {Duration expiration = const Duration(hours: 5), bool force = false}) { + throw UnimplementedError(); + } + + @override + bool getBool(String key, {bool defaultValue = Config.defaultValueForBool}) { + throw UnimplementedError(); + } + + @override + double getDouble(String key, + {double defaultValue = Config.defaultValueForDouble}) { + throw UnimplementedError(); + } + + @override + int getInt(String key, {int defaultValue = Config.defaultValueForInt}) { + throw UnimplementedError(); + } + + @override + String getString(String key, + {String defaultValue = Config.defaultValueForString}) { + throw UnimplementedError(); + } + + @override + bool hasKey(String key) { + return false; + } + + @override + Future setDefaults(Map defaults) async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('changeId test.', () async { + final tApp = createApp(); + final tUser = User(app: tApp); + + const tUserId = 'userId'; + final tProperties = {'propertyKey': 'propertyValue'}; + final tData = {'dataKey': 'dataValue'}; + const tOverrideId = 'overrideId'; + final tOverrideIdMap = {TestPluginA: tOverrideId}; + final tOverrideMap = { + 'propertyKey': 'overrideValue', + 'pluginKey': 'pluginValue', + }; + final tOverrideProperties = {TestPluginA: tOverrideMap}; + + await tUser.changeId( + tUserId, + properties: tProperties, + data: tData, + overrideId: tOverrideIdMap, + overrideProperties: tOverrideProperties, + ); + + expect(tUser.id, equals(tUserId)); + expect(tUser.properties, equals(tProperties)); + expect(await tUser.getData('dataKey'), equals('dataValue')); + expect(tUser.getPropertiesFor(), + equals(tUser.properties..addAll(tOverrideMap))); + expect(tUser.getIdFor(), equals(tOverrideId)); + expect(tUser.getPropertiesFor(), equals(tUser.properties)); + expect(tUser.getIdFor(), equals(tUserId)); + expect(tUser.variables.map((e) => e.unsafeValue), [ + tUserId, + tProperties, + tData, + ]); + + const tUserId2 = 'userId2'; + await tUser.changeId( + tUserId2, + ); + + expect(tUser.id, equals(tUserId2)); + expect(tUser.properties, equals({'propertyKey': null})); + }); + + test('set test.', () async { + final tApp = createApp(); + final tUser = User(app: tApp); + + final tProperties = {'propertyKey': 'propertyValue'}; + final tData = {'dataKey': 'dataValue'}; + final tOverrideMap = { + 'propertyKey': 'overrideValue', + 'pluginKey': 'pluginValue', + }; + final tOverrideProperties = {TestPluginA: tOverrideMap}; + + await tUser.set( + properties: tProperties, + data: tData, + overrideProperties: tOverrideProperties, + ); + + expect(tUser.properties, equals(tProperties)); + expect(await tUser.getData('dataKey'), equals('dataValue')); + expect(tUser.getPropertiesFor(), + equals(tUser.properties..addAll(tOverrideMap))); + expect(tUser.getPropertiesFor(), equals(tUser.properties)); + }); + + test('setProperties test.', () async { + final tApp = createApp(); + final tUser = User(app: tApp); + + const tPropertyKey = 'propertyKey'; + const tPropertyValue = 'propertyValue'; + final tProperties = {tPropertyKey: tPropertyValue}; + final tOverrideMap = { + tPropertyKey: 'overrideValue', + 'pluginKey': 'pluginValue', + }; + final tOverrideProperties = {TestPluginA: tOverrideMap}; + + await tUser.setProperties( + tProperties, + overrideProperties: tOverrideProperties, + ); + expect(tUser.properties, equals(tProperties)); + expect(tUser.getPropertiesFor(), + equals(tUser.properties..addAll(tOverrideMap))); + expect(tUser.getPropertiesFor(), equals(tUser.properties)); + }); + + test('getPropertie defaultValue test.', () async { + final tApp = createApp(); + final tUser = User(app: tApp); + + const tPropertyKey = 'propertyKey'; + const tPropertyValue = 'propertyValue'; + final tProperties = {tPropertyKey: tPropertyValue}; + + await tUser.setProperties(tProperties); + expect(tUser.properties, equals(tProperties)); + expect(tUser.getProperty(tPropertyKey), tPropertyValue); + expect(tUser.getProperty('aaa'), isNull); + expect(tUser.getProperty('aaa', 'default'), 'default'); + }); + + test('setData test.', () async { + final tApp = createApp(); + final tUser = User(app: tApp); + + const tKey = 'testKey'; + const tValue = 'testValue'; + await tUser.setData(tKey, tValue); + + expect(await tUser.getData(tKey), equals(tValue)); + expect(tUser.getDataSync(tKey), equals(tValue)); + expect(await tUser.getData('aaa'), isNull); + }); + + test('removeData test.', () async { + final tApp = createApp(); + final tUser = User(app: tApp); + + final tData = {'dataKey': 'dataValue', 'deleteDataKey': 'deleteDataValue'}; + + await tUser.set( + data: tData, + ); + expect(await tUser.getData('dataKey'), equals('dataValue')); + expect(await tUser.getData('deleteDataKey'), equals('deleteDataValue')); + + await tUser.removeData('deleteDataKey'); + expect(await tUser.getData('dataKey'), equals('dataValue')); + expect(await tUser.getData('deleteDataKey'), isNull); + }); + + test('removeAllData test.', () async { + final tApp = createApp(); + final tUser = User(app: tApp); + + final tData = {'dataKey': 'dataValue', 'deleteDataKey': 'deleteDataValue'}; + + await tUser.set( + data: tData, + ); + expect(await tUser.getData('dataKey'), equals('dataValue')); + expect(await tUser.getData('deleteDataKey'), equals('deleteDataValue')); + + await tUser.removeAllData(); + expect(await tUser.getData('dataKey'), isNull); + expect(await tUser.getData('deleteDataKey'), isNull); + }); + + test('addSynchronousChangeListener and removeSynchronousChangeListener test.', + () async { + final tApp = createApp(); + final tUser = User(app: tApp); + + const tUserId = 'userId'; + final Map tProperties = {'propertyKey': 'propertyValue'}; + final tData = {'dataKey': 'dataValue'}; + const tOverrideId = 'overrideId'; + final tOverrideIdMap = {TestPluginA: tOverrideId}; + final tOverrideMap = { + 'propertyKey': 'overrideValue', + 'pluginKey': 'pluginValue', + }; + final tOverrideProperties = {TestPluginA: tOverrideMap}; + + bool tCallbackCalled = false; + fCallback(User user, UserChangeData data) { + expect(data.id, equals(tUserId)); + expect(data.properties, equals(tProperties)); + expect(data.data, tData); + expect(data.getPropertiesFor(), tOverrideMap); + expect(data.getIdFor(), equals(tOverrideId)); + expect(data.getPropertiesFor(), tProperties); + expect(data.getIdFor(), equals(tUserId)); + + data.id = 'updateId'; + data.removeAllProperties(); + data.data.updateAll((key, value) => 'updateDataValue'); + + tCallbackCalled = true; + } + + tUser.addSynchronousChangeListener(fCallback); + await tUser.changeId( + tUserId, + properties: tProperties, + data: tData, + overrideId: tOverrideIdMap, + overrideProperties: tOverrideProperties, + ); + expect(tCallbackCalled, isTrue); + expect(tUser.id, equals('updateId')); + expect(tUser.properties, equals({'propertyKey': null})); + expect(await tUser.getData('dataKey'), equals('updateDataValue')); + expect( + tUser.getPropertiesFor(), + equals( + { + 'propertyKey': 'overrideValue', + 'pluginKey': 'pluginValue', + }, + ), + ); + expect(tUser.getIdFor(), equals(tOverrideId)); + + tCallbackCalled = false; + tUser.removeSynchronousChangeListener(fCallback); + await tUser.changeId( + tUserId, + properties: tProperties, + data: tData, + overrideId: tOverrideIdMap, + overrideProperties: tOverrideProperties, + ); + expect(tCallbackCalled, isFalse); + }); + + test('equals test.', () async { + final tApp = createApp(); + final tUser1 = User(app: tApp); + final tUser2 = User(app: tApp); + final tUser3 = User(app: tApp); + + const tUserId = 'userId'; + + await tUser1.changeId(tUserId); + await tUser2.changeId(tUserId); + await tUser3.changeId('user3'); + + expect(tUser1, equals(tUser2)); + expect(tUser1, isNot(tUser3)); + expect(tUser1.hashCode, equals(tUser2.hashCode)); + expect(tUser1.hashCode, isNot(tUser3.hashCode)); + expect({tUser1, tUser2, tUser3}.length, equals(2)); + + // ignore: invalid_use_of_protected_member + expect(tUser1.key, isNot(tUser2.key)); + }); + + test('set saves the value even if RemoteConfig fetch fails.', () async { + final tApp = createApp(); + await (tApp.remoteConfig as ProxyRemoteConfig) + .addRemoteConfig(FetchExceptionRemoteConfig()); + final tUser = User(app: tApp); + + final Map tProperties = {'propertyKey': 'propertyValue'}; + + await tUser.set(properties: tProperties); + + expect(tUser.properties, equals(tProperties)); + }); +} diff --git a/packages/patapata_core/test/util_test.dart b/packages/patapata_core/test/util_test.dart new file mode 100644 index 0000000..6010af6 --- /dev/null +++ b/packages/patapata_core/test/util_test.dart @@ -0,0 +1,458 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'utils/patapata_core_test_utils.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'package:timezone/timezone.dart' as tz; + +void main() { + test( + 'A SequentialWorkQueue can add a work item and it will execute', + () async { + final tQueue = SequentialWorkQueue(); + int tValue = 0; + + await tQueue.add(() { + tValue = 1; + }); + + expect( + tValue, + equals(1), + ); + }, + ); + + test( + 'A SequentialWorkQueue can add multiple work items and they will all execute in order', + () async { + final tQueue = SequentialWorkQueue(); + String tValue = ''; + + tQueue.add(() { + tValue += '0'; + }); + + tQueue.add(() { + tValue += '1'; + }); + + await tQueue.add(() { + tValue += '2'; + }); + + expect( + tValue, + equals('012'), + ); + }); + + test( + 'A SequentialWorkQueue completes all work\'s futures successfully, no matter the actual work\'s result', + () async { + final tQueue = SequentialWorkQueue(); + String tValue = ''; + List> tFutures = []; + + tFutures.add(tQueue.add(() { + tValue += '0'; + })); + + tFutures.add(tQueue.add(() async { + throw Error(); + }).catchError((error) => null)); + + tFutures.add(tQueue.add(() { + tValue += '2'; + })); + + await Future.wait(tFutures); + + expect( + tValue, + equals('02'), + ); + }); + + test('A SequentialWorkQueue can have work added to it even inside other work', + () async { + final tQueue = SequentialWorkQueue(); + String tValue = ''; + List> tFutures = []; + + tFutures.add(tQueue.add(() { + tValue += '0'; + + tFutures.add(tQueue.add(() { + tValue += '1'; + })); + })); + + tFutures.add(tQueue.add(() { + tFutures.add(tQueue.add(() { + tValue += '2'; + })); + + tValue += '3'; + })); + + while (tFutures.isNotEmpty) { + final tFuturesCopy = tFutures.toList(); + tFutures.clear(); + await Future.wait(tFuturesCopy); + } + + expect( + tValue, + equals('0132'), + ); + }); + + test( + 'A SequentialWorkQueue can be cleared, cancelling all current work that can be cancelled', + () async { + // No async. + var tQueue = SequentialWorkQueue(); + List> tFutures = []; + String tValue = ''; + + tFutures.add(tQueue.add(() { + tValue += '0'; + }, () => true)); + + tQueue.clear(); + + tFutures.add(tQueue.add(() { + tValue += '1'; + }, () => true)); + + while (tFutures.isNotEmpty) { + final tFuturesCopy = tFutures.toList(); + tFutures.clear(); + await Future.wait(tFuturesCopy); + } + + expect( + tValue, + equals('01'), + reason: 'No async', + ); + + // Async cancelled + tQueue = SequentialWorkQueue(); + tFutures.clear(); + tValue = ''; + + tFutures.add(tQueue.add(() async { + await Future.microtask(() => null); + tValue += '0'; + }, () async => await Future.microtask(() => true))); + + tQueue.clear(); + + tFutures.add(tQueue.add(() { + tValue += '1'; + }, () async => true)); + + while (tFutures.isNotEmpty) { + final tFuturesCopy = tFutures.toList(); + tFutures.clear(); + await Future.wait(tFuturesCopy); + } + + expect( + tValue, + equals('1'), + reason: 'Async cancelled', + ); + + // Async cancelled multiple times + tQueue = SequentialWorkQueue(); + tFutures.clear(); + tValue = ''; + + tFutures.add(tQueue.add(() async { + await Future.microtask(() => null); + tValue += '0'; + }, () async => await Future.microtask(() => true))); + + tFutures.add(tQueue.add(() async { + await Future.microtask(() => null); + tValue += '0'; + }, () async => await Future.microtask(() => true))); + + tQueue.clear(); + + tFutures.add(tQueue.add(() async { + await Future.microtask(() => null); + tValue += '0'; + }, () async => await Future.microtask(() => true))); + + tQueue.clear(); + + tFutures.add(tQueue.add(() { + tValue += '1'; + }, () async => true)); + + while (tFutures.isNotEmpty) { + final tFuturesCopy = tFutures.toList(); + tFutures.clear(); + await Future.wait(tFuturesCopy); + } + + expect( + tValue, + equals('1'), + reason: 'Async cancelled', + ); + + // Async no cancel + tQueue = SequentialWorkQueue(); + tFutures.clear(); + tValue = ''; + + tFutures.add(tQueue.add(() async { + await Future.microtask(() => null); + tValue += '0'; + }, () async => await Future.microtask(() => false))); + + tQueue.clear(); + + tFutures.add(tQueue.add(() { + tValue += '1'; + }, () async => true)); + + while (tFutures.isNotEmpty) { + final tFuturesCopy = tFutures.toList(); + tFutures.clear(); + await Future.wait(tFuturesCopy); + } + + expect( + tValue, + equals('01'), + reason: 'Async no cancel', + ); + }, + ); + + test( + 'typeIs should return true if the type of List is List with the same type', + () { + expect(typeIs(), isTrue); + expect(typeIs(), isTrue); + }); + + test( + 'typeIs should return false if the type of List is List with different types', + () { + expect(typeIs(), isFalse); + expect(typeIs(), isFalse); + }); + + test( + 'now should return the current DateTime', + () { + final tNow = now.toUTCIso8601StringNoMSUS(); + final tCurrentDateTime = DateTime.now().toUTCIso8601StringNoMSUS(); + + expect( + tNow, + equals(tCurrentDateTime), + ); + }, + // In the case of low probability failure, perform several retries. + retry: 5, + ); + + test( + 'platformCompute test', + () async { + const int tData = 5; + + final tResult = await platformCompute( + (input) => input * 2, + tData, + ); + + expect(tResult, 10); + }, + ); + + test( + 'platformCompute throw exception test', + () async { + const int tTestData = 5; + + Future testFunction() async { + await platformCompute( + (input) async => throw Exception('error'), + tTestData, + ); + } + + // Assert check + expect(testFunction, throwsException); + }, + ); + + testWidgets( + 'scheduleFunction should schedule the provided function to be executed after the next frame if frames are enabled', + (WidgetTester tester) async { + bool tExecuted = false; + bool tPostFrameCallbackCalled = false; + onPostFrameCallback = () { + tPostFrameCallbackCalled = true; + }; + + scheduleFunction(() { + tExecuted = true; + }); + + // Test that tExecuted is false before pumping. + expect(tExecuted, false); + + await tester.pump(); + + expect(tExecuted, true); + expect(tPostFrameCallbackCalled, isTrue); + }, + ); + group('timezone test group', () { + late tz.Location tOriginalTimeZone; + setUp(() { + tz.initializeTimeZones(); + tOriginalTimeZone = tz.local; + }); + tearDown(() { + tz.setLocalLocation(tOriginalTimeZone); + }); + test('toUTCIso8601StringNoMSUS should return the date in ISO8601 format', + () { + // I want to test changes from a time zone other than UTC, so I will test with the time zone set to Asia/Tokyo. + final tz.Location tTimeZone = tz.getLocation('Asia/Tokyo'); + + tz.TZDateTime tDateTime = tz.TZDateTime(tTimeZone, 2022, 1, 1, 12, 0, 0); + expect("2022-01-01T03:00:00Z", tDateTime.toUTCIso8601StringNoMSUS()); + + tDateTime = tz.TZDateTime(tTimeZone, 12345, 7, 9, 3, 15, 0, 0); + expect("012345-07-08T18:15:00Z", tDateTime.toUTCIso8601StringNoMSUS()); + + tDateTime = tz.TZDateTime(tTimeZone, 2022, 1, 1, 12, 0, 0); + expect("2022-01-01T03:00:00Z", tDateTime.toUTCIso8601StringNoMSUS()); + }); + }); + + testWidgets("DateTime fake time test", (WidgetTester tester) async { + // Create an instance of the App before calling the setFakeNow function, + // as setFakeNow internally invokes getApp(). + final App tApp = createApp( + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.shrink(), + ), + ), + ); + + await tApp.run(); + + await tApp.runProcess(() async { + final Stream tEventStream = fakeNowStream; + DateTime? tFakeNow; + + var tFutureFakeNow = expectLater( + tEventStream.asyncMap( + (dateTime) => dateTime?.toUTCIso8601StringNoMSUS(), + ), + emitsInOrder([null]), + ); + + setFakeNow(tFakeNow); + + // expect fake test + await tFutureFakeNow; + expect(fakeNowSet, false); + + tFakeNow = DateTime(2022, 1, 1, 12, 0, 0, 0, 0); + + tFutureFakeNow = expectLater( + tEventStream.asyncMap( + (dateTime) => dateTime?.toUTCIso8601StringNoMSUS(), + ), + emitsInOrder([tFakeNow.toUTCIso8601StringNoMSUS()]), + ); + + setFakeNow(tFakeNow); + + await tester.pumpAndSettle(); + + // expect fake test + await tFutureFakeNow; + expect(fakeNowSet, true); + expect(now, tFakeNow); + + tFakeNow = DateTime(2022, 1, 1, 14, 0, 0, 0, 0); + + tFutureFakeNow = expectLater( + tEventStream.asyncMap( + (dateTime) => dateTime?.toUTCIso8601StringNoMSUS(), + ), + emitsInOrder([tFakeNow.toUTCIso8601StringNoMSUS()]), + ); + + int tBeforeMicroseconds = !kIsWeb ? now.microsecondsSinceEpoch : 0; + + setFakeNow(tFakeNow, elapse: true); + + await tester.pumpAndSettle(); + + int tAfterMicroseconds = !kIsWeb ? now.microsecondsSinceEpoch : 0; + + if (!kIsWeb) { + expect(tAfterMicroseconds, isNot(tBeforeMicroseconds)); + } + + tApp.dispose(); + }); + }); + + testWidgets("DateTime load fake now test", (WidgetTester tester) async { + // Create an instance of the App before calling the setFakeNow function, + // as setFakeNow internally invokes getApp(). + final tDateTimeNow = + DateTime(2022, 1, 1, 14, 0, 0, 0, 0).toUTCIso8601StringNoMSUS(); + + final App tApp = createApp( + appWidget: const MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SizedBox.shrink(), + ), + ), + plugins: [ + Plugin.inline( + createLocalConfig: () => MockLocalConfig({ + 'patapataFakeNow': tDateTimeNow, + }), + ), + ], + ); + + await tApp.run(); + + await tApp.runProcess(() async { + await tester.pumpAndSettle(); + + expect(now.toUTCIso8601StringNoMSUS(), tDateTimeNow); + }); + + tApp.dispose(); + }); +} diff --git a/packages/patapata_core/test/utils/patapata_core_test_utils.dart b/packages/patapata_core/test/utils/patapata_core_test_utils.dart new file mode 100644 index 0000000..8bade57 --- /dev/null +++ b/packages/patapata_core/test/utils/patapata_core_test_utils.dart @@ -0,0 +1,268 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +import '../pages/home_page.dart'; +import '../pages/notification_page.dart'; +import '../pages/error_page.dart'; + +class Environment with I18nEnvironment, NotificationsEnvironment { + const Environment(); + + @override + List? get l10nPaths => [ + 'l10n', + ]; + + @override + List? get supportedL10ns => [ + const Locale('ja'), + const Locale('en'), + ]; + + @override + List get notificationsAndroidChannels => [ + const AndroidNotificationChannel( + 'custom', + 'Patapata Test Channel', + importance: Importance.max, + enableLights: true, + ) + ]; + + @override + String get notificationsAndroidDefaultIcon => 'ic_notification'; + + @override + bool get notificationsDarwinDefaultPresentAlert => false; + + @override + bool get notificationsDarwinDefaultPresentBadge => false; + + @override + bool get notificationsDarwinDefaultPresentSound => false; + + @override + bool get notificationsDarwinDefaultPresentBanner => false; + + @override + bool get notificationsDarwinDefaultPresentList => false; +} + +class MockL10nAssetBundle extends CachingAssetBundle { + @override + Future load(String key) { + final tYamlMap = { + 'l10n/en.yaml': ''' +home: + title: HomePage +notification: + title: NotificationPage +test: + title: TestMessage:{param} +errors: + test: + '000': + title: ErrorTitle:{prefix}{data} + message: ErrorMessage:{prefix}{data} + fix: ErrorFix:{prefix}{data} + shout: + '111': + title: ErrorPageTitle + message: ErrorPageMessage + fix: ErrorPageFix + +''', + 'l10n/ja.yaml': ''' +home: + title: ホーム +notification: + title: 通知 +test: + title: テストメッセージ:{param} +errors: + test: + '000': + title: エラー:{prefix}{data} + message: メッセージ:{prefix}{data} + fix: 修復:{prefix}{data} + shout: + '111': + title: エラーページ:タイトル + message: エラーページ:メッセージ + fix: エラーページ:修復 +''', + 'l10n2/en.yaml': ''' +home: + title: HomePage2 +test2: + title: TestMessage2:{param} +''', + 'l10n2/ja.yaml': ''' +home: + title: ホーム2 +test2: + title: テストメッセージ2:{param} +''', + 'parse_error/en.yaml': ''' +home + title: This message will not be displayed because it will result in a parse error. +''', + 'empty/en.yaml': ''' +''', + }; + + final tCompleter = Completer(); + + try { + final tByteData = ByteData.view( + Uint8List.fromList( + utf8.encode( + tYamlMap[key]!, + ), + ).buffer, + ); + tCompleter.complete(tByteData); + } catch (e, stackTrace) { + tCompleter.completeError(e, stackTrace); + } + + return tCompleter.future; + } +} + +class _MockStreamHandler extends MockStreamHandler { + _MockStreamHandler(this.handler); + + final TestMockStreamHandler? handler; + + @override + void onCancel(Object? arguments) { + handler?.onCancel(arguments); + } + + @override + void onListen(Object? arguments, MockStreamHandlerEventSink events) { + handler?.onListen(arguments, _MockStreamHandlerEventSink(other: events)); + } +} + +class _MockStreamHandlerEventSink extends TestMockStreamHandlerEventSink { + final MockStreamHandlerEventSink other; + + _MockStreamHandlerEventSink({ + required this.other, + }); + + @override + void endOfStream() { + other.endOfStream(); + } + + @override + void error({required String code, String? message, Object? details}) { + other.error(code: code, message: message, details: details); + } + + @override + void success(Object? event) { + other.success(event); + } +} + +bool _testInitialized = false; + +void testInitialize() { + if (_testInitialized) { + return; + } + + _testInitialized = true; + + TestWidgetsFlutterBinding.ensureInitialized(); + + testSetMockMethodCallHandler = TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger.setMockMethodCallHandler; + testSetMockStreamHandler = (channel, handler) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + channel, + _MockStreamHandler(handler), + ); + }; + + PackageInfoPlugin.setMockValues( + appName: 'mock_patapata_core', + packageName: 'io.flutter.plugins.mockpatapatacore', + version: '1.0', + buildNumber: '1', + buildSignature: 'patapata_core_build_signature', + installerStore: null, + ); + + mockL10nAssetBundle = MockL10nAssetBundle(); +} + +final _patapataCoreTestKey = GlobalKey(); +App createApp({ + Object? environment, + Widget? appWidget, + StartupSequence? startupSequence, + List? plugins, +}) { + testInitialize(); + + return App( + plugins: plugins, + environment: environment ?? const Environment(), + createAppWidget: (context, app) => + appWidget ?? + StandardMaterialApp( + onGenerateTitle: (context) => 'title', + pages: [ + StandardPageFactory( + create: (data) => HomePage(), + ), + StandardPageFactory( + create: (pageData) => NotificationPage(), + links: {r'notification/': (match, uri) {}}, + linkGenerator: (pageData) => 'notification/', + ), + StandardErrorPageFactory( + create: (pageData) => ErrorPage(), + ), + ], + routableBuilder: (context, child) { + return KeyedSubtree( + key: _patapataCoreTestKey, + child: child!, + ); + }, + ), + startupSequence: startupSequence, + ); +} + +Future setTestDeviceSize( + WidgetTester tester, + Size tTestDeviceSize, { + double devicePixelRatio = 1.0, + double textScaleFactorTestValue = 1.0, +}) async { + // Set the device size to the test device size. + await tester.binding.setSurfaceSize(tTestDeviceSize); + tester.view.physicalSize = tTestDeviceSize; + tester.view.devicePixelRatio = devicePixelRatio; + tester.binding.platformDispatcher.textScaleFactorTestValue = + textScaleFactorTestValue; +} diff --git a/packages/patapata_core/test/utils/standard_app_widget_test_data.dart b/packages/patapata_core/test/utils/standard_app_widget_test_data.dart new file mode 100644 index 0000000..a5c1cb8 --- /dev/null +++ b/packages/patapata_core/test/utils/standard_app_widget_test_data.dart @@ -0,0 +1,670 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:provider/provider.dart'; + +const String kTestChangePageDataButton = 'test-change-page-data-button'; +const String kTestButton = 'test-button'; +const String kTestButtonSecond = 'test-button-second'; +const String kLinkButton = 'link-button'; +const String kOnPopButton = 'on-pop-button'; +const String kGoPageDataButton = 'go-page-data-button'; +const String kGoChangeDataButton = 'go-change-data-button'; +const String kChangePageDataButton = 'change-page-data-button'; +const String kProcessInitialRouteButton = 'process-initial-route-button'; +const String kRemoveRouteButton = 'remove-route-buton'; +const String kCheckNavigatorButton = 'check-navigator-buton'; +const String kGoToRouterDelegateButton = 'go-to-router-delegate-button'; +const String kGetPageFactoryButton = 'get-page-factory-button'; +const String kGetStandardAppRouterContext = 'get-standard-app-router-context'; +const String kGetStandardAppPluginRoute = 'get-standard-app-plugin-route'; +const String kGetStandardAppPluginGenerateLink = + 'get-standard-app-plugin-generate-link'; + +TestAnalyticsEvent? testAnalytics; +GlobalKey? navigatorKey; +NavigatorState? navigatorState; +BuildContext? navigatorContext; +StandardPageWithResultFactory? pageFactory; +bool isLinkHander = false; +Uri? linkHanderUri; +BuildContext? buildContext; + +bool onLink(Uri link) { + isLinkHander = true; + linkHanderUri = link; + return true; +} + +// Test Data +class TestChangeNotifierData extends ChangeNotifier { + TestChangeNotifierData({ + required this.title, + required this.pages, + }); + + String title = ''; + List pages = []; + Widget Function(BuildContext context, Widget? child)? routableBuilder; + bool Function(Route route, dynamic result)? willPopPage; + + void changeTitle(String title) { + this.title = title; + notifyListeners(); + } + + void changePages(List pages) { + this.pages = pages; + notifyListeners(); + } + + void changeRoutableBuilder( + Widget Function(BuildContext context, Widget? child)? routableBuilder, + ) { + this.routableBuilder = routableBuilder; + notifyListeners(); + } + + void changeWillPopPage( + bool Function(Route route, dynamic result)? willPopPage, + ) { + this.willPopPage = willPopPage; + notifyListeners(); + } +} + +// Material Page +class TestPageA extends StandardPage { + @override + AnalyticsEvent? get analyticsSingletonEvent => testAnalytics; + + String testText = ''; + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, 'Test Title'), + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text("Test Message"); + }, + ), + ), + TextButton( + key: const ValueKey(kTestButton), + onPressed: () { + context.go(null); + }, + child: const Text('Go to Test Page B'), + ), + TextButton( + key: const ValueKey(kGoToRouterDelegateButton), + onPressed: () { + // Go To Test Page B for Router Delegate + var routerDelegate = (Router.of(context).routerDelegate + as StandardRouterDelegate); + routerDelegate.go(null); + }, + child: const Text('Go to Test Page B'), + ), + TextButton( + key: const ValueKey(kLinkButton), + onPressed: () { + context.route('testPageData/10'); + }, + child: const Text('Go to Link Page'), + ), + TextButton( + key: const ValueKey(kGoPageDataButton), + onPressed: () { + context.go( + TestPageData(id: 10, data: 'go to test data page data'), + ); + }, + child: const Text('Go to Test TestPage C'), + ), + TextButton( + key: const ValueKey(kGoChangeDataButton), + onPressed: () { + context.go(ChangeListenableBool()); + }, + child: const Text('Go to Test Page E'), + ), + TextButton( + key: const ValueKey(kProcessInitialRouteButton), + onPressed: () { + Router.of(context).processInitialRoute(); + }, + child: const Text('Go to Test Page E'), + ), + TextButton( + key: const ValueKey(kRemoveRouteButton), + onPressed: () { + Router.of(context).removeRoute(context); + }, + child: const Text('Remove Route'), + ), + TextButton( + key: const ValueKey(kGetPageFactoryButton), + onPressed: () { + var routerDelegate = (Router.of(context).routerDelegate + as StandardRouterDelegate); + pageFactory = + routerDelegate.getPageFactory(); + }, + child: const Text('Get Page Factory'), + ), + TextButton( + key: const ValueKey(kGetStandardAppRouterContext), + onPressed: () { + buildContext = context; + }, + child: const Text('Get StandardAppRouter Context'), + ), + TextButton( + key: const ValueKey(kGetStandardAppPluginRoute), + onPressed: () { + getApp() + .getPlugin() + ?.route('testPageData/10'); + }, + child: const Text('Get StandardAppRouter Context'), + ), + if (analyticsSingletonEvent == null) ...[ + TextButton( + key: const ValueKey(kGetStandardAppPluginGenerateLink), + onPressed: () { + testText = context + .read() + .getPlugin()! + .generateLink( + TestPageData(id: 9999, data: 'test page data'), + ) ?? + ''; + setState(() {}); + }, + child: const Text('Get Standard GenerateLink App'), + ), + Center( + child: Text('generateLink testText $testText'), + ), + ], + if (analyticsSingletonEvent != null) + Center( + child: Text( + "Analytics Event Data : ${analyticsSingletonEvent!.data}"), + ), + ], + ), + ), + ); + } +} + +class TestPageB extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'Test Title B', + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text("Test Message B"); + }, + ), + ), + TextButton( + key: const ValueKey(kTestButton), + onPressed: () { + context.go( + TestPageData(id: 40, data: 'Go to Page C'), + ); + }, + child: const Text('On pop page'), + ), + TextButton( + key: const ValueKey(kOnPopButton), + onPressed: () { + Navigator.maybePop(context); + }, + child: const Text('On pop page'), + ), + TextButton( + key: const ValueKey(kTestButtonSecond), + onPressed: () { + context.go( + null, + ); + }, + child: const Text('Go to Test TestPage D'), + ), + TextButton( + key: const ValueKey(kRemoveRouteButton), + onPressed: () { + Router.of(context).removeRoute(context); + }, + child: const Text('Remove Route'), + ), + TextButton( + key: const ValueKey(kCheckNavigatorButton), + onPressed: () { + navigatorState = (Router.of(context).routerDelegate + as StandardRouterDelegate) + .navigator; + navigatorContext = (Router.of(context).routerDelegate + as StandardRouterDelegate) + .navigatorContext; + navigatorKey = (Router.of(context).routerDelegate + as StandardRouterDelegate) + .navigatorKey; + }, + child: const Text('Set Navigator Key'), + ), + ], + ), + ), + ); + } +} + +class TestPageC extends StandardPage { + @override + Widget buildPage(BuildContext context) { + String? tGenerateLink; + final ModalRoute? tRoute = ModalRoute.of(context); + if (tRoute != null) { + final tInterfacePage = tRoute.settings as StandardPageInterface; + tGenerateLink = tInterfacePage.factoryObject.generateLink(pageData); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Test Title C'), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text("Test Message C"); + }, + ), + ), + Center( + child: Builder( + builder: (context) { + return Text("Test Link Data is ${pageData.id}"); + }, + ), + ), + TextButton( + key: const ValueKey(kGoPageDataButton), + onPressed: () { + context.go( + TestPageData(id: 20, data: 'go to test page data'), + ); + + setState(() {}); + }, + child: const Text('Go to Test TestPageC'), + ), + TextButton( + key: const ValueKey(kTestChangePageDataButton), + onPressed: () { + pageData = TestPageData( + id: 30, data: 'changed test page data by button'); + setState(() {}); + }, + child: const Text('On Change PageData'), + ), + TextButton( + key: const ValueKey(kOnPopButton), + onPressed: () { + Navigator.maybePop(context); + }, + child: const Text('On pop page'), + ), + if (tGenerateLink != null) + Center( + child: Builder( + builder: (context) { + return Text("Test Generate Link is $tGenerateLink"); + }, + ), + ), + ], + ), + ), + ); + } +} + +class TestPageD extends StandardPage { + @override + Widget buildPage(BuildContext context) { + var tModalRoute = ModalRoute.of(context); + var tInterfacePage = tModalRoute?.settings as StandardPageInterface; + var tTestPageData = tInterfacePage.arguments as TestPageData; + + return Scaffold( + appBar: AppBar( + title: Text( + l(context, 'Test Title D'), + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Text("Test Page Id : ${tTestPageData.id}"), + Text("Test Page Data : ${tTestPageData.data}"), + Text("Test Interface Name : ${tInterfacePage.name}"), + Text( + "Test Interface RestorationId : ${tInterfacePage.restorationId}"), + ], + ), + ), + ); + } +} + +class TestPageE extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, 'Test Title'), + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + TextButton( + key: const ValueKey(kChangePageDataButton), + onPressed: () { + if (pageData is ChangeListenableBool) { + pageData = ChangeListenableNumber(); + } else { + pageData = ChangeListenableBool(); + } + setState(() {}); + }, + child: const Text('On Change PageData Bool Type'), + ), + if (pageData is ChangeListenableBool) + Center( + child: Text( + "Test Data Bool : ${context.watch().data}"), + ), + if (pageData is ChangeListenableNumber) + Center( + child: Text( + "Test Data Number : ${context.watch().data}"), + ), + ], + ), + ), + ); + } +} + +// Cupertino Page +class TestPageF extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Test title F'), + ), + child: SingleChildScrollView( + child: Column( + children: [ + Center( + child: Builder( + builder: (context) { + return const Text("Test Message F"); + }, + ), + ), + ], + ), + ), + ); + } +} + +class TestPageG extends StandardPage { + @override + void initState() { + super.initState(); + + pageData = TestPageData(id: 77, data: 'overridden test page data'); + } + + @override + Widget buildPage(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Test title G'), + ), + child: SingleChildScrollView( + child: Column( + children: [ + Center( + child: Builder( + builder: (context) { + return Text('${pageData.id} ${pageData.data}'); + }, + ), + ), + ], + ), + ), + ); + } +} + +class TestPageH extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Test title H'), + ), + child: SingleChildScrollView( + child: Column( + children: [ + Center( + child: Builder( + builder: (context) { + return Text('${pageData.id} ${pageData.data}'); + }, + ), + ), + ], + ), + ), + ); + } +} + +class TestPageRemoveRoute extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('TestPageRemoveRoute'), + ), + body: SingleChildScrollView( + child: Column( + children: [ + TextButton( + key: const ValueKey(kTestButton), + onPressed: () async { + context.removeRoute(); + }, + child: const Text('Remove the route'), + ), + ], + ), + ), + ); + } +} + +class TestPageContextGoWithResult extends StandardPage { + String? _result; + + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, 'TestPageContextGoWithResult'), + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + TextButton( + key: const ValueKey(kTestButton), + onPressed: () async { + final tResult = await context + .goWithResult(null); + setState(() { + _result = tResult; + }); + }, + child: const Text('Go to Test Page For Result'), + ), + Text(_result ?? ''), + ], + ), + ), + ); + } +} + +class TestPageWithResult extends StandardPageWithResult { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + l(context, 'TestPageWithResult'), + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + TextButton( + key: const ValueKey(kTestButton), + onPressed: () { + pageResult = 'pageResult'; + Navigator.pop(context); + }, + child: const Text('Go to Test Page For Result'), + ), + TextButton( + key: const ValueKey(kTestButtonSecond), + onPressed: () { + Navigator.pop(context, 'popResult'); + }, + child: const Text('On pop page'), + ), + ], + ), + ), + ); + } +} + +// Test Page Data +class TestPageData { + final int id; + final String? data; + + TestPageData({ + required this.id, + required this.data, + }); +} + +class BaseListenable extends ChangeNotifier {} + +class ChangeListenableBool extends BaseListenable { + bool data = true; +} + +class ChangeListenableNumber extends BaseListenable { + int data = 100; +} + +class TestAnalyticsEvent extends AnalyticsEvent { + TestAnalyticsEvent({ + required String name, + Map? data, + }) : super(name: name, data: data); +} + +class TestDataPlugin extends Plugin with StandardAppRoutePluginMixin { + @override + Future getInitialRouteData() async { + return StandardRouteData( + factory: null, + pageData: TestPageData(id: 9999, data: 'test plugin data'), + ); + } +} + +class TestDataParseRouteInformationPlugin extends Plugin + with StandardAppRoutePluginMixin { + @override + Future parseRouteInformation( + RouteInformation routeInformation) async { + return SynchronousFuture(StandardRouteData( + factory: app + .getPlugin()! + .delegate! + .getPageFactory(), + pageData: TestPageData(id: 70, data: 'parsed route'), + )); + } +} + +class TestDataBadLinkHandlerPlugin extends Plugin + with StandardAppRoutePluginMixin { + @override + Future getInitialRouteData() async { + return StandardRouteData( + factory: null, + pageData: TestPageData(id: 9999, data: 'test plugin data'), + ); + } +} + +class TestNullDataPlugin extends Plugin with StandardAppRoutePluginMixin {} diff --git a/packages/patapata_example_app/.gitignore b/packages/patapata_example_app/.gitignore new file mode 100644 index 0000000..4314849 --- /dev/null +++ b/packages/patapata_example_app/.gitignore @@ -0,0 +1,44 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release \ No newline at end of file diff --git a/packages/patapata_example_app/.metadata b/packages/patapata_example_app/.metadata new file mode 100644 index 0000000..009f8af --- /dev/null +++ b/packages/patapata_example_app/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "efbf63d9c66b9f6ec30e9ad4611189aa80003d31" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + - platform: android + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + - platform: ios + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + - platform: linux + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + - platform: macos + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + - platform: web + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + - platform: windows + create_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + base_revision: efbf63d9c66b9f6ec30e9ad4611189aa80003d31 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/patapata_example_app/README.md b/packages/patapata_example_app/README.md new file mode 100644 index 0000000..eea85c1 --- /dev/null +++ b/packages/patapata_example_app/README.md @@ -0,0 +1,29 @@ +
    +

    Patapata - Example App

    +

    + Introduction of sample code using Patapata's features. +

    +
    + +--- + +## About +This package is a sample application that demonstrates how to use the features of `patapata_core`. The processing begins from the main function in `lib/main.dart`. + +## Getting started +To run the sample application, either execute `lib/main.dart` or run `flutter run`. + +This app also supports the Cupertino design. To run with Cupertino design, either specify `--dart-define=APP_TYPE=cupertino` in the args of `launch.json` or provide `--dart-define=APP_TYPE=cupertino` as an argument with flutter run. +``` +flutter run --dart-define=APP_TYPE=cupertino +``` + +Note that the default is `--dart-define=APP_TYPE=material`, which applies the Material Design when executed. + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/LICENSE) diff --git a/packages/patapata_example_app/analysis_options.yaml b/packages/patapata_example_app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/packages/patapata_example_app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/patapata_example_app/android/.gitignore b/packages/patapata_example_app/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/packages/patapata_example_app/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/packages/patapata_example_app/android/app/build.gradle b/packages/patapata_example_app/android/app/build.gradle new file mode 100644 index 0000000..5d30270 --- /dev/null +++ b/packages/patapata_example_app/android/app/build.gradle @@ -0,0 +1,67 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "com.example.patapata_example_app" + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.patapata_example_app" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion 21 + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies {} diff --git a/packages/patapata_example_app/android/app/src/debug/AndroidManifest.xml b/packages/patapata_example_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/patapata_example_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/patapata_example_app/android/app/src/main/AndroidManifest.xml b/packages/patapata_example_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b684296 --- /dev/null +++ b/packages/patapata_example_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/patapata_example_app/android/app/src/main/kotlin/com/example/patapata_example_app/MainActivity.kt b/packages/patapata_example_app/android/app/src/main/kotlin/com/example/patapata_example_app/MainActivity.kt new file mode 100644 index 0000000..04040f2 --- /dev/null +++ b/packages/patapata_example_app/android/app/src/main/kotlin/com/example/patapata_example_app/MainActivity.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.example.patapata_example_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/packages/patapata_example_app/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/patapata_example_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/packages/patapata_example_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/patapata_example_app/android/app/src/main/res/drawable/launch_background.xml b/packages/patapata_example_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/packages/patapata_example_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/patapata_example_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/patapata_example_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/patapata_example_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/patapata_example_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/patapata_example_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/android/app/src/main/res/values-night/styles.xml b/packages/patapata_example_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/packages/patapata_example_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/patapata_example_app/android/app/src/main/res/values/styles.xml b/packages/patapata_example_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/packages/patapata_example_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/patapata_example_app/android/app/src/profile/AndroidManifest.xml b/packages/patapata_example_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/packages/patapata_example_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/patapata_example_app/android/build.gradle b/packages/patapata_example_app/android/build.gradle new file mode 100644 index 0000000..f7eb7f6 --- /dev/null +++ b/packages/patapata_example_app/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/packages/patapata_example_app/android/gradle.properties b/packages/patapata_example_app/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/packages/patapata_example_app/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/packages/patapata_example_app/android/gradle/wrapper/gradle-wrapper.properties b/packages/patapata_example_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c472b9 --- /dev/null +++ b/packages/patapata_example_app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/packages/patapata_example_app/android/settings.gradle b/packages/patapata_example_app/android/settings.gradle new file mode 100644 index 0000000..55c4ca8 --- /dev/null +++ b/packages/patapata_example_app/android/settings.gradle @@ -0,0 +1,20 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + plugins { + id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false + } +} + +include ":app" + +apply from: "${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/packages/patapata_example_app/ios/.gitignore b/packages/patapata_example_app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/packages/patapata_example_app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/patapata_example_app/ios/Flutter/AppFrameworkInfo.plist b/packages/patapata_example_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..9625e10 --- /dev/null +++ b/packages/patapata_example_app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/packages/patapata_example_app/ios/Flutter/Debug.xcconfig b/packages/patapata_example_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/packages/patapata_example_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/patapata_example_app/ios/Flutter/Release.xcconfig b/packages/patapata_example_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/packages/patapata_example_app/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/patapata_example_app/ios/Podfile b/packages/patapata_example_app/ios/Podfile new file mode 100644 index 0000000..164df53 --- /dev/null +++ b/packages/patapata_example_app/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/patapata_example_app/ios/Podfile.lock b/packages/patapata_example_app/ios/Podfile.lock new file mode 100644 index 0000000..492e198 --- /dev/null +++ b/packages/patapata_example_app/ios/Podfile.lock @@ -0,0 +1,53 @@ +PODS: + - connectivity_plus (0.0.1): + - Flutter + - ReachabilitySwift + - device_info_plus (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - patapata_core (0.0.1): + - Flutter + - ReachabilitySwift (5.0.0) + +DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - patapata_core (from `.symlinks/plugins/patapata_core/ios`) + +SPEC REPOS: + trunk: + - ReachabilitySwift + +EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + Flutter: + :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + patapata_core: + :path: ".symlinks/plugins/patapata_core/ios" + +SPEC CHECKSUMS: + connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d + device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 + package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 + patapata_core: ce5b4c8f74bd32c7c405f0d85d4cbbf25cb75da6 + ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 + +PODFILE CHECKSUM: 7be2f5f74864d463a8ad433546ed1de7e0f29aef + +COCOAPODS: 1.12.1 diff --git a/packages/patapata_example_app/ios/Runner.xcodeproj/project.pbxproj b/packages/patapata_example_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fc1a51d --- /dev/null +++ b/packages/patapata_example_app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,617 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807E294A63A400263BE5 /* Frameworks */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = E225A72QFV; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AE0B7B92F70575B8D7E0D07E /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 89B67EB44CE7B6631473024E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 640959BDD8F10B91D80A66BE /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = E225A72QFV; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = E225A72QFV; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/patapata_example_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/patapata_example_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/patapata_example_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..87131a0 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_example_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/patapata_example_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/patapata_example_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/patapata_example_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/patapata_example_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/patapata_example_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/packages/patapata_example_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/patapata_example_app/ios/Runner/AppDelegate.swift b/packages/patapata_example_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..0f67170 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner/AppDelegate.swift @@ -0,0 +1,18 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/patapata_example_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/patapata_example_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/packages/patapata_example_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_example_app/ios/Runner/Base.lproj/Main.storyboard b/packages/patapata_example_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_example_app/ios/Runner/Info.plist b/packages/patapata_example_app/ios/Runner/Info.plist new file mode 100644 index 0000000..04ba3db --- /dev/null +++ b/packages/patapata_example_app/ios/Runner/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Patapata Example App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + patapata_example_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + FlutterDeepLinkingEnabled + + + \ No newline at end of file diff --git a/packages/patapata_example_app/ios/Runner/Runner-Bridging-Header.h b/packages/patapata_example_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/packages/patapata_example_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/patapata_example_app/ios/RunnerTests/RunnerTests.swift b/packages/patapata_example_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..4923924 --- /dev/null +++ b/packages/patapata_example_app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,17 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/patapata_example_app/l10n/ar.yaml b/packages/patapata_example_app/l10n/ar.yaml new file mode 100644 index 0000000..a93827f --- /dev/null +++ b/packages/patapata_example_app/l10n/ar.yaml @@ -0,0 +1,102 @@ +title: عينة نواة تطبيق باتاباتا +plurals: + test1: هذا اختبار بـ {count, plural, other{# عناصر}}. +errors: + app: + '000': + title: خطأ ({prefix}000) + message: خطأ غير معروف. + network: + '404': + title: خطأ ({prefix}404) + message: لا يمكن العثور على المحتوى. + '500': + title: خطأ ({prefix}500) + message: خطأ في الخادم الداخلي. + '503': + title: خطأ ({prefix}503) + message: الخدمة غير متوفرة. يخضع الخادم حاليًا للصيانة. + fix: اذهب إلى الصفحة الرئيسية +pages: + agreement: + title: اتفاق + body: هذه صفحة الاتفاق. هل توافق؟ + yes: نعم + no: لا + top: + title: الصفحة الرئيسية لنموذج باتاباتا + body: هذه هي صفحة البداية العينية لباتاباتا. + go_to_config: نموذج التكوين + go_to_screen_layout: تخطيط الشاشة العيني + go_to_standard_page: نموذج الصفحة القياسي + go_to_device_and_pakage_info: معلومات الجهاز والحزمة العينية + go_to_error: نموذج الخطأ + go_to_tab: نموذج علامة التبويب + config: + title: نموذج التكوين + body: هذه صفحة التكوين العينية. + increment: زيادة العدد + clear: مسح العدد المحلي للتكوين + error: + title: نموذج الخطأ + body: هذه صفحة الخطأ العينية. + example: خطأ عادي + network: خطأ في الشبكة ({prefix}) + maintenance: خطأ الصيانة + maintenance: + title: صيانة الخادم + message: غير متاح حاليًا بسبب صيانة الخادم. الرجوع إلى الصفحة الرئيسية. + device_and_package_info: + title: نموذج معلومات الجهاز والحزمة + body: هذه صفحة عينة لاسترجاع معلومات الجهاز ومعلومات الحزمة. + model: اسم الجهاز + app_name: اسم التطبيق + build_number: رقم البناء + build_signature: توقيع البناء + package_name: اسم الحزمة + version: رقم الإصدار + standard_page: + title: نموذج الصفحة القياسي + body: هذه صفحة عينة من الصفحة القياسية. + go_to_next_standard_page: الانتقال إلى الصفحة القياسية التالية + go_to_custom_standard_page: الانتقال إلى الصفحة القياسية المخصصة + go_to_page_data: الانتقال إلى عينة بيانات الصفحة + page_data_value: "قيمة بيانات الصفحة: {prefix}" + change_page_data: تغيير بيانات الصفحة + page_data_count: "عدد بيانات الصفحة: {prefix}" + change_page_data_type: تغيير نوع بيانات الصفحة + change_page_data_result: "القيمة الحالية لبيانات الصفحة: {prefix}" + tab: + home: + title: الرئيسية + body: هذه علامة التبويب الرئيسية. + title_details: + title: تفاصيل العنوان + body: هذه علامة التبويب تفاصيل العنوان. + my_page: + title: صفحتي + body: هذه علامة التبويب صفحتي. + screen_layout_example: + title: عينة تخطيط الشاشة + body: هذا عينة لتخطيط الشاشة. + sample_a: عينة A + sample_b: عينة B + base_description_before: | + تم تحديد هذا SizedBox بأبعاد 300x300، والSizedBoxes على اليمين واليسار لها عرض 37.5. + قد يظهر هناك هوامش أو ربما Flutter يعرض تحذيرًا بالتجاوز بسبب حجم الشاشة غير الكافي. + عن طريق استخدام نظام ScreenLayout، يتم تكبير أو تصغير RenderSize للطفل بناءً على القيم المحددة في StandardBreakpoint (ولكنه لا يتجاوز أبدًا maxScale). + العينة A هي الكائن الخام دون تطبيق ScreenLayout. + base_description_after: | + العينة B هي نتيجة تطبيق ScreenLayout على العينة A. إذا كان حجم الشاشة الفعلي 450 أو أقل، يجب أن يكون الحجم مثاليًا. + يكون هذا مفيدًا عند إنشاء ويدجت لا ترغب في تغيير مظهرها بشكل جذري بناءً على عرض الشاشة أو عندما ترغب في محاذاة الأحجام النسبية + بين الأجهزة اللوحية والأجهزة غير اللوحية، على سبيل المثال، عند عرض الحوارات. + description_case_equal: بسبب عرض جهازك البالغ {width}، لذلك يتم عرض الودجت بشكل مثالي. (إذا كنت ترغب في اختبار وظيفة ScreenLayout، يرجى تشغيلها على جهاز بعرض مختلف عن 375). + description_case_over: بسبب عرض جهازك البالغ {width}، هناك هوامش على اليسار واليمين. + description_case_other: بسبب عرض جهازك البالغ {width}، يتم عرض تحذير بالتجاوز. + description_sample: | + في المثال السابق، قمنا بتوضيح تكبير RenderSize بناءً على StandardBreakpoint. ومع ذلك، يعتمد حجم الودجت الفعلي على حجم والديها. + ومع ذلك، قد تكون هناك حالات ترغب في فرض قيود على الحجم الفعلي. في مثل هذه الحالات، يمكنك استخدام ConstrainedWidth. + من خلال تحديد ConstrainedWidth، يمكنك تغيير الحجم الفعلي والاستفادة لاحقًا من الحساب التلقائي لـ RenderSize بناءً على StandardBreakpoint. + إنها فعّالة أيضًا لإحاطة ويدجت ScreenLayout بـ ConstrainedBox أو نهج مماثل لتقييد الحجم. + يرجى اختيار النهج المناسب بناءً على الوضع في شجرة الودجت الخاصة بك. + description_example: في المثال أعلاه، تم تعيين قيمة ConstrainedWidth إلى 200. يقوم هذا بتحديد الحجم الفعلي إلى 200، ولكن يمكن ملاحظة الشكل النسبي للمحتوى. diff --git a/packages/patapata_example_app/l10n/en.yaml b/packages/patapata_example_app/l10n/en.yaml new file mode 100644 index 0000000..ae38559 --- /dev/null +++ b/packages/patapata_example_app/l10n/en.yaml @@ -0,0 +1,102 @@ +title: Patapata Core Sample App +plurals: + test1: This is a test with {count, plural, other{# items}}. +errors: + app: + '000': + title: Error ({prefix}000) + message: Unknown Error. + network: + '404': + title: Error ({prefix}404) + message: Contents not found. + '500': + title: Error ({prefix}500) + message: Internal server error. + '503': + title: Error ({prefix}503) + message: Service Unavailable. The server is currently undergoing maintenance. + fix: Go to Top Page +pages: + agreement: + title: Agreement + body: This is the agreement page. Do you accept? + yes: Yes + no: No + top: + title: Patapata Sample Top + body: This is the sample top page of Patapata. + go_to_config: Config Sample + go_to_screen_layout: ScreenLayout Sample + go_to_standard_page: StandardPage Sample + go_to_device_and_pakage_info: Device And PackageInfo Sample + go_to_error: Error Sample + go_to_tab: Tab Sample + config: + title: Config Sample + body: This is the sample page of Config. + increment: Increment Count + clear: Clear Local Config Count + error: + title: Error Sample + body: This is the sample page of Error. + example: Normal Error + network: Network Error({prefix}) + maintenance: Maintenance Error + maintenance: + title: Server Maintenance + message: Currently unavailable due to server maintenance. Returning to the Top Page. + device_and_package_info: + title: Sample of Device and Package Information + body: This is a sample page for retrieving device information and package information. + model: device name + app_name: app name + build_number: build number + build_signature: build signature + package_name: package name + version: version number + standard_page: + title: StandardPage Sample + body: This is a sample page of StandardPage. + go_to_next_standard_page: Go to next StandardPage + go_to_custom_standard_page: Go to Custom StandardPage + go_to_page_data: Go to Page Data Sample + page_data_value: PageData Value:{prefix} + change_page_data: Change PageData + page_data_count: PageData Count:{prefix} + change_page_data_type: Change PageData Type + change_page_data_result: Current PageData Value:{prefix} + tab: + home: + title: Home + body: This is the home tab. + title_details: + title: Title Details + body: This is the title details tab. + my_page: + title: My Page + body: This is the my page tab. + screen_layout_example: + title: ScreenLayout Sample + body: This is a sample of ScreenLayout. + sample_a: Sample A + sample_b: Sample B + base_description_before: | + This SizedBox is specified as 300x300, and the SizedBoxes on the left and right have a Width of 37.5. + Depending on the actual screen size, there may be margins or Flutter may display an overflow warning due to insufficient screen size. + By using the ScreenLayout system, the RenderSize of the child is scaled up or down based on the values set in the StandardBreakpoint (but it never exceeds the maxScale). + Sample A is the raw object without applying ScreenLayout. + base_description_after: | + Sample B is the result of applying ScreenLayout to Sample A. If the actual screen size is 450 or less, it should be the perfect size. + This is useful when creating widgets that you don\'t want to drastically change their appearance based on screen width or when you want to align relative sizes + between tablets and non-tablet devices, for example, when displaying dialogs. + description_case_equal: Due to your device width of {width}, so the widget is displayed perfectly. (If you want to test the functionality of ScreenLayout, please try running it on a device with a width other than 375). + description_case_over: Due to your device width of {width}, there are margins on the left and right. + description_case_other: Due to your device width of {width}, an overflow warning is being displayed. + description_sample: | + In the previous example, we demonstrated scaling the RenderSize based on the StandardBreakpoint. However, the actual widget size depends on the size of its parent. + However, there are cases where you may want to impose restrictions on the actual size. In such cases, you can use ConstrainedWidth. + By specifying ConstrainedWidth, you can change the actual size and still benefit from the automatic calculation of RenderSize based on the StandardBreakpoint. + It is also effective to enclose the ScreenLayout widget with a ConstrainedBox or similar approach to restrict the size. + Please choose the appropriate approach based on the situation in your widget tree. + description_example: In the above example, the value of ConstrainedWidth is set to 200. This limits the actual size to 200, but it can be observed that the relative shape of the content is preserved. \ No newline at end of file diff --git a/packages/patapata_example_app/l10n/ja.yaml b/packages/patapata_example_app/l10n/ja.yaml new file mode 100644 index 0000000..0571293 --- /dev/null +++ b/packages/patapata_example_app/l10n/ja.yaml @@ -0,0 +1,102 @@ +title: Patapata Core サンプルアプリ +plurals: + test1: これは{count, plural, other{#個}}の項目があるテストです。 +errors: + app: + '000': + title: エラー({prefix}000) + message: 不明エラーが発生しました。 + network: + '404': + title: エラー({prefix}404) + message: コンテンツが見つかりませんでした。 + '500': + title: エラー({prefix}500) + message: サーバーエラーが発生しました。 + '503': + title: サーバーメンテナンス + message: 現在サーバーメンテンス中のため利用できません。トップページに戻ります。 + fix: トップ画面へ +pages: + agreement: + title: 同意画面 + body: これは同意画面です。承諾しますか? + yes: はい + no: いいえ + top: + title: Patapata サンプル トップ + body: ここはPatapataのサンプルトップページです。 + go_to_config: Configのサンプル + go_to_screen_layout: ScreenLayoutのサンプル + go_to_standard_page: StandardPageのサンプル + go_to_device_and_pakage_info: DeviceInfoとPackageInfoのサンプル + go_to_error: Errorのサンプル + go_to_tab: Tabのサンプル + config: + title: Config サンプル + body: ここはConfigのサンプルページです。 + increment: カウントを増やす + clear: Local Configを削除する + error: + title: エラー サンプル + body: ここはエラー機能のサンプルページです。 + example: 通常のエラー + network: ネットワークエラー({prefix}) + maintenance: メンテナンス中エラー + maintenance: + title: サーバーメンテナンス + message: 現在サーバーメンテンス中のため利用できません。トップページに戻ります。 + device_and_package_info: + title: デバイスとパッケージ情報 サンプル + body: ここはデバイス情報とパッケージ情報を取得するサンプルページです。 + model: デバイス名 + app_name: アプリ名 + build_number: ビルド番号 + build_signature: ビルドサイン(build signature) + package_name: パッケージ名 + version: バージョン番号 + standard_page: + title: StandardPage サンプル + body: ここはStandardPageのサンプルページです。 + go_to_next_standard_page: 次のStandardPageへ + go_to_custom_standard_page: カスタムStandardPageへ + go_to_page_data: ページデータのサンプルへ + page_data_value: PageDataの値:{prefix} + change_page_data: PageDataの値を変更する + page_data_count: PageDataのカウント数:{prefix} + change_page_data_type: PageDataの型を変更 + change_page_data_result: 現在のPageDataの値:{prefix} + tab: + home: + title: ホーム + body: ここはホームのタブです。 + title_details: + title: タイトル詳細 + body: ここはタイトル詳細のタブです。 + my_page: + title: マイページ + body: ここはマイページのタブです。 + screen_layout_example: + title: ScreenLayout サンプル + body: ここはScreenLayoutのサンプルページです。 + sample_a: サンプルA + sample_b: サンプルB + base_description_before: | + このSizedBoxは300x300として指定されており、左右のSizedBoxはそれぞれ幅が37.5です。 + 実際の画面サイズに依存して、マージンが発生するか、Flutterが画面サイズが不足しているためにオーバーフローの警告を表示する可能性があります。 + ScreenLayout システムを使用することで、子要素の RenderSize は StandardBreakpoint で設定された値に基づいて拡大または縮小されます(ただし、maxScale を超えることはありません)。 + サンプルAは、ScreenLayoutを適用せずに生のオブジェクトです。 + base_description_after: | + サンプルBは、サンプルAにScreenLayoutを適用した結果です。実際の画面サイズが450以下の場合、これは完璧なサイズになるはずです。 + これは、画面の幅に基づいて外観を急激に変更したくないウィジェットを作成する場合や、 + タブレットと非タブレットのデバイス間で相対的なサイズを整列させたい場合(たとえばダイアログを表示する場合など)に役立ちます。 + description_case_equal: デバイスの幅が{width}であるため、ウィジェットは完璧に表示されます。(ScreenLayoutの機能をテストしたい場合は、幅が375でないデバイスで実行してみてください)。 + description_case_over: デバイスの幅が{width}であるため、左右にマージンがあります。 + description_case_other: デバイスの幅が{width}であるため、オーバーフローの警告が表示されています。 + description_sample: | + 前述の例では、StandardBreakpointに基づいてRenderSizeをスケーリングする方法を示しましたが、実際のウィジェットサイズはその親のサイズに依存します。 + ただし、実際のサイズに制約を加えたい場合があります。そのような場合、ConstrainedWidthを使用できます。 + ConstrainedWidthを指定することで、実際のサイズを変更し、依然としてStandardBreakpointに基づいてRenderSizeを自動計算する利点を得ることができます。 + また、サイズを制約するためにScreenLayoutウィジェットをConstrainedBoxなどで囲むと効果的です。 + ウィジェットツリー内の状況に基づいて適切なアプローチを選択してください。 + description_example: 上記の例では、ConstrainedWidthの値が200に設定されています。これにより、実際のサイズが200に制限されますが、コンテンツの相対的な形状が保持されていることが観察されます。 \ No newline at end of file diff --git a/packages/patapata_example_app/lib/exceptions.dart b/packages/patapata_example_app/lib/exceptions.dart new file mode 100644 index 0000000..95afa50 --- /dev/null +++ b/packages/patapata_example_app/lib/exceptions.dart @@ -0,0 +1,61 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +import '../src/errors.dart'; + +/// This is the exception class that occurs in this sample application. +/// Exceptions generated by an app using Patapata should inherit from [PatapataException]. +/// Please override the necessary members as specified. +base class ExampleException extends AppException { + const ExampleException({ + super.app, + super.message, + super.original, + super.fingerprint, + super.localeTitleData, + super.localeMessageData, + super.localeFixData, + super.fix, + super.logLevel, + super.userLogLevel, + }); + + @override + String get internalCode => '000'; +} + +/// This is the exception class for network-related issues that occur in this sample application. +base class ExampleNetworkException extends ExampleException { + final int statusCode; + + const ExampleNetworkException({ + required this.statusCode, + super.fix, + super.logLevel, + super.userLogLevel, + }); + + @override + String get defaultPrefix => 'APN'; + + @override + String get internalCode => '$statusCode'; + + @override + String get namespace => 'app.network'; +} + +/// This is the exception class for maintenance-related issues that occur in this sample application. +final class ExampleMaintenanceException extends ExampleNetworkException { + const ExampleMaintenanceException({ + required super.fix, + }) : super( + statusCode: 503, + userLogLevel: Level.SHOUT, + ); +} diff --git a/packages/patapata_example_app/lib/main.dart b/packages/patapata_example_app/lib/main.dart new file mode 100644 index 0000000..050c59a --- /dev/null +++ b/packages/patapata_example_app/lib/main.dart @@ -0,0 +1,326 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_example_app/src/pages/device_and_package_info_page.dart'; +import 'package:patapata_example_app/src/pages/standard_page_example_page.dart'; +import 'package:provider/provider.dart'; + +import 'page_data.dart'; +import 'src/cupertino/pages/top_page.dart'; +import 'src/cupertino/pages/home_page.dart'; +import 'src/cupertino/pages/my_page.dart'; +import 'src/environment.dart'; +import 'src/pages/config_page.dart'; +import 'src/pages/home_page.dart'; +import 'src/pages/my_page.dart'; +import 'src/pages/screen_layout_example_page.dart'; +import 'src/pages/top_page.dart'; +import 'src/startup.dart'; +import 'src/pages/error_page.dart'; +import 'src/pages/splash_page.dart'; +import 'src/pages/agreement_page.dart'; + +final _providerKey = GlobalKey(debugLabel: 'AppProviderKey'); +final logger = Logger('patapata.example'); + +void main() { + App( + environment: const Environment(), + startupSequence: StartupSequence( + startupStateFactories: [ + StartupStateFactory( + (startupSequence) => StartupStateCheckVersion(startupSequence), + [ + LogicStateTransition(), + ], + ), + StartupStateFactory( + (startupSequence) => StartupStateAgreements(startupSequence), + [], + ), + ], + ), + createAppWidget: _createAppWidget, + plugins: [], + providerKey: _providerKey, + ) + ..getPlugin()?.enableStandardAppIntegration() + ..run(() async { + // Do any initialization here + // Here's a good default + + // Set a default orientation of only portrait + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitDown, + DeviceOrientation.portraitUp, + ]); + + // Enable Edge-to-Edge mode + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + + // Make the status bars transparent + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + systemNavigationBarColor: Colors.transparent, + statusBarColor: Colors.transparent, + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + )); + + // Set your RemoteConfig defaults here + await getApp().remoteConfig.setDefaults(const {}); + }); +} + +/// Returns either [StandardMaterialApp] or [StandardCupertinoApp] to be passed to [App.createAppWidget]. +/// The choice between the two depends on [Environment.appType] in [App.environment]. +/// If [flutter run --dart-define=APP_TYPE=cupertino] is used, it uses [StandardCupertinoApp]. +/// Otherwise, it uses [StandardMaterialApp]. +Widget _createAppWidget(BuildContext context, App app) { + if (app.environment.appType == 'cupertino') { + return StandardCupertinoApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + // Splash screen page. + // This uses a special factory that has good defaults for splash screens. + SplashPageFactory( + create: (_) => SplashPage(), + ), + // Agreement page. + // This uses a special factory that all StartupSequence pages should use. + StartupPageFactory( + create: (_) => AgreementPage(), + ), + // Cupertino Top Page. + // This is the top page to navigate to after the AgreementPage. + // On this page, there are links to sample pages showcasing various features available in Patapata. + StandardPageFactory( + create: (_) => CupertinoTopPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + // Cupertino Tab and pages. + // CupertinoTitlePage are pages related to the tabs on the CupertinoHomePage. + // The parent of the tabs in Home is the CupertinoHomePage, and the first child page displayed within that tab is the CupertinoTitlePage. + StandardPageFactory( + create: (data) => CupertinoHomePage(), + ), + StandardPageFactory( + create: (data) => CupertinoTitlePage(), + parentPageType: CupertinoHomePage, + ), + // CupertinoMyFavoritePage is a page related to the tabs on CupertinoMyPage. + // The parent of the tabs in CupertinoMyPage is CupertinoMyPage itself, and the first child page displayed within that tab is CupertinoMyFavoritePage. + StandardPageFactory( + create: (data) => CupertinoMyPage(), + ), + StandardPageFactory( + create: (data) => CupertinoMyFavoritePage(), + parentPageType: CupertinoMyPage, + ), + ], + routableBuilder: (context, child) { + // Setup [ScreenLayout] + // You may want to move this to the body section of your Scaffold + // or somewhere where it makes sense for your app's design. + child = ScreenLayout(child: child); + + // Wrap the app in a key provided by you + // so you can access your providers from anywhere + // via context.read and context.watch. + child = KeyedSubtree( + key: _providerKey, + child: child, + ); + + return child; + }, + ); + } else { + return StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + // Splash screen page. + SplashPageFactory( + create: (_) => SplashPage(), + ), + // Agreement page. + StartupPageFactory( + create: (_) => AgreementPage(), + ), + // Error page. + StandardErrorPageFactory( + create: (_) => ErrorPage(), + ), + // Top Page. + StandardPageFactory( + create: (_) => TopPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + groupRoot: true, + ), + // LocalConfig Sample Page. + StandardPageFactory( + create: (_) => ConfigPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + ), + // DeviceInfo and PackageInfo Sample Page. + StandardPageFactory( + create: (_) => DeviceAndPackageInfoPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + ), + // Error Select Page. + StandardPageFactory( + create: (_) => ErrorSelectPage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + ), + // Material Tab and pages. + // TitlePage and TitleDetailsPage are pages related to the tabs on the HomePage. + // The parent of the tabs in Home is the HomePage, + // and the first child page displayed within that tab is the TitlePage. + StandardPageFactory( + create: (data) => HomePage(), + ), + StandardPageFactory( + create: (data) => TitlePage(), + parentPageType: HomePage, + ), + StandardPageFactory( + create: (data) => TitleDetailsPage(), + parentPageType: HomePage, + ), + // MyFavoritePage is a page related to the tabs on MyPage. + // The parent of the tabs in MyPage is MyPage itself, + // and the first child page displayed within that tab is MyFavoritePage. + StandardPageFactory( + create: (data) => MyPage(), + ), + StandardPageFactory( + create: (data) => MyFavoritePage(), + parentPageType: MyPage, + ), + // The page with an example implementation of ScreenLayout. + StandardPageFactory( + create: (data) => ScreenLayoutExamplePage(), + ), + // Error page. + StandardErrorPageFactory( + create: (data) => ErrorPage(), + ), + // StandardPage And PageData Sample Page. + StandardPageFactory( + create: (_) => StandardPageExamplePage(), + links: { + r'': (match, uri) {}, + }, + linkGenerator: (pageData) => '', + ), + // HasDataPage is a page that receives PageData and displays it. + StandardPageFactory( + create: (data) => HasDataPage(), + ), + // CustomStandardPage is a page where the functionality of StandardPage is customized using pageBuilder. + // It customizes things like transition animations during navigation. + StandardPageFactory( + create: (data) => CustomStandardPage(), + pageBuilder: ( + child, + name, + pageData, + pageKey, + restorationId, + standardPageKey, + factoryObject, + ) { + return StandardCustomPage( + name: "Custom Page", + arguments: PageData(), + key: pageKey, + restorationId: restorationId, + standardPageKey: standardPageKey, + factoryObject: factoryObject, + opaque: true, + barrierDismissible: true, + barrierColor: Colors.blueAccent, + child: Column( + children: [ + Expanded(child: child), + const Text("test"), + ], + ), + transitionDuration: const Duration(milliseconds: 500), + transitionBuilder: + (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: + Tween(begin: const Offset(0, 1), end: Offset.zero) + .animate(animation), + child: child, + ); + }, + ); + }, + ), + // ChangeListenablePage is an example where, instead of PageData, + // it can observe changes such as ChangeNotifier. + // BaseListenable inherits from ChangeNotifier. + StandardPageFactory( + create: (data) => ChangeListenablePage(), + ) + ], + routableBuilder: (context, child) { + // Setup [ScreenLayout] + // You may want to move this to the body section of your Scaffold + // or somewhere where it makes sense for your app's design. + child = ScreenLayout(child: child); + + // Wrap the app in a key provided by you + // so you can access your providers from anywhere + // via context.read and context.watch. + child = KeyedSubtree( + key: _providerKey, + child: child, + ); + + // If you want to customize a Theme, you can do it here + // by wrapping the child with a Theme widget. + // You can also wrap anything here and that Widget will + // be available to all pages. + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (context) => CountData(), + ), + ChangeNotifierProvider( + create: (context) => ChangeListenableBool(), + ), + ChangeNotifierProvider( + create: (context) => ChangeListenableNumber(), + ), + ], + child: child, + ); + }, + ); + } +} diff --git a/packages/patapata_example_app/lib/page_data.dart b/packages/patapata_example_app/lib/page_data.dart new file mode 100644 index 0000000..25b1491 --- /dev/null +++ b/packages/patapata_example_app/lib/page_data.dart @@ -0,0 +1,34 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +// This file contains the bundled PageData used as an example in StandardPage. + +import 'package:flutter/widgets.dart'; + +class PageData { + PageData({ + this.hello = 'hi!', + }); + final String hello; +} + +class CountData extends ChangeNotifier { + int count = 0; + + void increment() { + count = count + 1; + notifyListeners(); + } +} + +class BaseListenable extends ChangeNotifier {} + +class ChangeListenableBool extends BaseListenable { + bool data = false; +} + +class ChangeListenableNumber extends BaseListenable { + int data = 100; +} diff --git a/packages/patapata_example_app/lib/src/cupertino/pages/home_page.dart b/packages/patapata_example_app/lib/src/cupertino/pages/home_page.dart new file mode 100644 index 0000000..de9f998 --- /dev/null +++ b/packages/patapata_example_app/lib/src/cupertino/pages/home_page.dart @@ -0,0 +1,42 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/cupertino.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_example_app/src/cupertino/widgets/app_tab.dart'; + +/// This sample transforms the HomePage, designed with Material's design, into Cupertino's design. +/// The structure remains the same as HomePage. +class CupertinoHomePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + // When creating an application with features like a footer, please return childNavigator in the buildPage method. + return childNavigator ?? const SizedBox.shrink(); + } +} + +/// This sample transforms the TitlePage, designed with Material's design, into Cupertino's design. +/// The basic structure remains the same as TitlePage, but it uses an AppBar tailored for Cupertino. +class CupertinoTitlePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return CupertinoAppBar( + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return Text( + l(context, 'pages.tab.home.body'), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/cupertino/pages/my_page.dart b/packages/patapata_example_app/lib/src/cupertino/pages/my_page.dart new file mode 100644 index 0000000..a9d90b4 --- /dev/null +++ b/packages/patapata_example_app/lib/src/cupertino/pages/my_page.dart @@ -0,0 +1,42 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/cupertino.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_example_app/src/cupertino/widgets/app_tab.dart'; + +/// This sample transforms the MyPage, designed with Material's design, into Cupertino's design. +/// The structure remains the same as MyPage. +class CupertinoMyPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + // When creating an application with features like a footer, please return childNavigator in the buildPage method. + return childNavigator ?? const SizedBox.shrink(); + } +} + +/// This sample transforms the MyFavoritePage, designed with Material's design, into Cupertino's design. +/// The basic structure remains the same as MyFavoritePage, but it uses an AppBar tailored for Cupertino. +class CupertinoMyFavoritePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return CupertinoAppBar( + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return Text( + l(context, 'pages.tab.my_page.body'), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/cupertino/pages/top_page.dart b/packages/patapata_example_app/lib/src/cupertino/pages/top_page.dart new file mode 100644 index 0000000..214a14b --- /dev/null +++ b/packages/patapata_example_app/lib/src/cupertino/pages/top_page.dart @@ -0,0 +1,33 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/cupertino.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +import 'home_page.dart'; + +/// Cupertino version of the TopPage. +class CupertinoTopPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(l(context, 'pages.top.title')), + ), + child: ListView( + children: [ + Text(l(context, 'pages.top.body')), + CupertinoButton( + onPressed: () { + context.go(null); + }, + child: Text(l(context, 'pages.top.go_to_tab')), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/cupertino/widgets/app_tab.dart b/packages/patapata_example_app/lib/src/cupertino/widgets/app_tab.dart new file mode 100644 index 0000000..09c3584 --- /dev/null +++ b/packages/patapata_example_app/lib/src/cupertino/widgets/app_tab.dart @@ -0,0 +1,69 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/cupertino.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +import '../pages/home_page.dart'; +import '../pages/my_page.dart'; + +/// Sample StandardApp for Cupertino with a widget displayed in a tab. +class CupertinoAppBar extends StatelessWidget { + const CupertinoAppBar({ + Key? key, + required this.body, + this.appBar, + }) : super(key: key); + + final Widget body; + final Widget? appBar; + + @override + Widget build(BuildContext context) { + var tInterfacePage = + ModalRoute.of(context)!.settings as StandardPageInterface; + var tType = tInterfacePage.factoryObject.parentPageType; + int tIndex = 0; + if (tType == CupertinoHomePage) { + tIndex = 0; + } else if (tType == CupertinoMyPage) { + tIndex = 1; + } + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + leading: const StandardPageBackButton(), + middle: tIndex == 0 + ? Text(l(context, 'pages.tab.home.title')) + : Text(l(context, 'pages.tab.my_page.title')), + ), + child: Column( + children: [ + Expanded( + child: body, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + CupertinoButton( + onPressed: () { + context.go(null); + }, + child: const Icon(CupertinoIcons.home), + ), + CupertinoButton( + onPressed: () { + context.go(null); + }, + child: const Icon(CupertinoIcons.heart), + ), + ], + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/environment.dart b/packages/patapata_example_app/lib/src/environment.dart new file mode 100644 index 0000000..7a7c769 --- /dev/null +++ b/packages/patapata_example_app/lib/src/environment.dart @@ -0,0 +1,41 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:flutter/widgets.dart'; + +/// The Environment class for this app. +/// Controls all static settings for the app. +/// Pass this to the [App] constructor. +/// [appType]" is a variable passed as a command-line argument with the name "APP_TYPE" when executed through 'flutter run. +class Environment with I18nEnvironment, LogEnvironment { + @override + final List supportedL10ns = const [ + Locale('en'), + Locale('ja'), + Locale('ar'), + ]; + + @override + final List l10nPaths = const [ + 'l10n', + ]; + + @override + final int logLevel; + + @override + final bool printLog; + + const Environment({ + this.logLevel = + const int.fromEnvironment('LOG_LEVEL', defaultValue: -kPataInHex), + this.printLog = + const bool.fromEnvironment('PRINT_LOG', defaultValue: kDebugMode), + }); + + final String? appType = const String.fromEnvironment('APP_TYPE'); +} diff --git a/packages/patapata_example_app/lib/src/errors.dart b/packages/patapata_example_app/lib/src/errors.dart new file mode 100644 index 0000000..3c41c8c --- /dev/null +++ b/packages/patapata_example_app/lib/src/errors.dart @@ -0,0 +1,56 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +/// This is the abstract class for exceptions that occur in the sample application. +abstract base class AppException extends PatapataException { + const AppException({ + super.app, + super.message, + super.original, + super.fingerprint, + super.localeTitleData, + super.localeMessageData, + super.localeFixData, + super.fix, + super.logLevel, + super.userLogLevel, + }); + + @override + String get defaultPrefix => 'APE'; + + @override + String get namespace => 'app'; +} + +/// An exception that is thrown when the app encounters an unknown error. +final class AppUnknownException extends AppException { + const AppUnknownException(); + + @override + String get internalCode => '000'; +} + +/// Thrown when an unsupported version (usually old) of the app is detected. +final class AppVersionException extends AppException { + const AppVersionException() : super(logLevel: Level.INFO); + + @override + String get internalCode => '010'; + + @override + void onReported(ReportRecord record) { + showDialog(getApp().navigatorContext); + } + + @override + Future Function()? get fix => () async { + // Launch the app store. + }; +} diff --git a/packages/patapata_example_app/lib/src/pages/agreement_page.dart b/packages/patapata_example_app/lib/src/pages/agreement_page.dart new file mode 100644 index 0000000..aa45aab --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/agreement_page.dart @@ -0,0 +1,43 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +/// This is an implementation of a screen where users can agree to the app's terms and conditions. +/// It appears after the splash screen. If the user does not agree, they will be taken back to the splash screen again. +class AgreementPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.agreement.title')), + ), + body: Column( + children: [ + Center( + child: Text( + l(context, 'pages.agreement.body'), + ), + ), + TextButton( + child: Text(l(context, 'pages.agreement.yes')), + onPressed: () { + pageData(null); + }, + ), + TextButton( + child: Text(l(context, 'pages.agreement.no')), + onPressed: () { + // Calling the sequence processing again will resume the sequence from the beginning. + getApp().startupSequence?.resetMachine(); + }, + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/config_page.dart b/packages/patapata_example_app/lib/src/pages/config_page.dart new file mode 100644 index 0000000..010fcf4 --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/config_page.dart @@ -0,0 +1,57 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:provider/provider.dart'; + +/// This is a page demonstrating how to use LocalConfig. +/// Here, values are being saved, modified, and retrieved for a LocalConfig with the key name counter. +class ConfigPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.config.title')), + ), + body: ListView( + children: [ + Center( + child: Text(l(context, 'pages.config.body')), + ), + Center( + child: Builder( + builder: (context) { + // Retrieve the value of counter from LocalConfig. + return Text( + l(context, 'plurals.test1', { + 'count': context + .select((v) => v.getInt('counter')) + }), + ); + }, + ), + ), + TextButton( + onPressed: () { + // Set the value of counter in LocalConfig. + getApp().localConfig.setInt( + 'counter', getApp().localConfig.getInt('counter') + 1); + }, + child: Text(l(context, 'pages.config.increment')), + ), + TextButton( + onPressed: () { + // Delete the value stored in LocalConfig. + getApp().localConfig.reset('counter'); + }, + child: Text(l(context, 'pages.config.clear')), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/device_and_package_info_page.dart b/packages/patapata_example_app/lib/src/pages/device_and_package_info_page.dart new file mode 100644 index 0000000..243556f --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/device_and_package_info_page.dart @@ -0,0 +1,134 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +/// This is a page demonstrating how to use DeviceInfo and PackageInfo. +class DeviceAndPackageInfoPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + // Get the device model name. + // If the platform is Web, you can retrieve the browser name. + String model = ""; + if (kIsWeb) { + model = getApp().device.webInfo!.browserName.toString(); + } else if (defaultTargetPlatform == TargetPlatform.android) { + model = getApp().device.androidDeviceInfo!.model; + } else if (defaultTargetPlatform == TargetPlatform.iOS) { + model = getApp().device.iosDeviceInfo!.model; + } else if (defaultTargetPlatform == TargetPlatform.windows) { + model = getApp().device.windowsInfo!.computerName; + } else if (defaultTargetPlatform == TargetPlatform.linux) { + model = getApp().device.linuxInfo!.prettyName; + } else if (defaultTargetPlatform == TargetPlatform.macOS) { + model = getApp().device.macOsInfo!.model; + } + + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.device_and_package_info.title')), + ), + body: ListView( + children: [ + Center( + child: Text( + l(context, 'pages.device_and_package_info.body'), + ), + ), + Table( + border: TableBorder.all(), + children: [ + TableRow( + children: [ + TableCell( + child: Center( + child: Text( + l(context, 'pages.device_and_package_info.model')), + ), + ), + TableCell( + child: Center(child: Text(model)), + ), + ], + ), + TableRow( + children: [ + TableCell( + child: Center( + child: Text( + l(context, 'pages.device_and_package_info.app_name')), + ), + ), + TableCell( + // Get the app name from PackageInfo. + child: Center(child: Text(getApp().package.info.appName)), + ), + ], + ), + TableRow( + children: [ + TableCell( + child: Center( + child: Text(l(context, + 'pages.device_and_package_info.build_number'))), + ), + TableCell( + // Get the build number from PackageInfo. + child: + Center(child: Text(getApp().package.info.buildNumber)), + ), + ], + ), + TableRow( + children: [ + TableCell( + child: Center( + child: Text(l(context, + 'pages.device_and_package_info.build_signature'))), + ), + TableCell( + // Get the build signature from PackageInfo. + child: Center( + child: Text(getApp().package.info.buildSignature)), + ), + ], + ), + TableRow( + children: [ + TableCell( + child: Center( + child: Text(l(context, + 'pages.device_and_package_info.package_name'))), + ), + TableCell( + // Get the package name from PackageInfo. + child: + Center(child: Text(getApp().package.info.packageName)), + ), + ], + ), + TableRow( + children: [ + TableCell( + child: Center( + child: Text(l( + context, 'pages.device_and_package_info.version'))), + ), + // Get the app version from PackageInfo. + TableCell( + child: Center(child: Text(getApp().package.info.version)), + ), + ], + ), + ], + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/error_page.dart b/packages/patapata_example_app/lib/src/pages/error_page.dart new file mode 100644 index 0000000..fb4f883 --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/error_page.dart @@ -0,0 +1,131 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../exceptions.dart'; +import '../../main.dart'; +import '../errors.dart'; + +/// ErrorPage is a page that is displayed when an error occurs in the application. +class ErrorPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + if (pageData.error is AppException) { + return _buildAppExceptionPage(context); + } else { + return _buildUnknownExceptionPage(context); + } + } + + Widget _buildAppExceptionPage(BuildContext context) { + final tAppException = pageData.error as AppException; + + return Scaffold( + appBar: AppBar( + title: Text(tAppException.localizedTitle), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Text(tAppException.localizedMessage), + if (tAppException.hasFix) + TextButton( + child: Text(tAppException.localizedFix), + onPressed: () { + tAppException.fix!(); + }, + ), + ], + ), + ), + ); + } + + Widget _buildUnknownExceptionPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'errors.app.000.title')), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Text(l(context, 'errors.app.000.message')), + ], + ), + ), + ); + } +} + +/// ErrorSelectPage is a page that allows you to select the type of error to display. +class ErrorSelectPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.error.title')), + ), + body: ListView( + children: [ + // Throw an exception named ExampleException, defined in the sample app, and log it. + TextButton( + onPressed: () { + try { + throw const ExampleException(); + } on PatapataException catch (e) { + e.showDialog(context); + logger.severe(e.toString(), e); + } + }, + child: Text(l(context, 'pages.error.example')), + ), + // Throw a network exception named ExampleNetworkException, defined in the sample app, and log it. + // Specify the network status code in the statusCode argument of ExampleNetworkException. + TextButton( + onPressed: () { + try { + throw const ExampleNetworkException(statusCode: 404); + } on PatapataException catch (e) { + e.showDialog(context); + logger.severe(e.toString(), e); + } + }, + child: Text(l(context, 'pages.error.network', {'prefix': '404'})), + ), + TextButton( + onPressed: () { + try { + throw const ExampleNetworkException(statusCode: 500); + } on PatapataException catch (e) { + e.showDialog(context); + logger.severe(e.toString(), e); + } + }, + child: Text(l(context, 'pages.error.network', {'prefix': '500'})), + ), + // Throw ExampleMaintenanceException, transition to the maintenance page, and navigate to ErrorSelectPage. + TextButton( + onPressed: () { + final tDelegate = context.read().standardAppPlugin.delegate; + try { + throw ExampleMaintenanceException(fix: () async { + tDelegate?.go( + null, StandardPageNavigationMode.removeAll); + }); + } catch (e) { + logger.severe(e.toString(), e); + } + }, + child: Text(l(context, 'pages.error.maintenance')), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/home_page.dart b/packages/patapata_example_app/lib/src/pages/home_page.dart new file mode 100644 index 0000000..3bfdfd6 --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/home_page.dart @@ -0,0 +1,77 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_example_app/src/widgets/app_tab.dart'; + +/// [HomePage] is the parent StandardPage for applications with a tabbed footer. +/// This HomePage has two child StandardPages, namely [TitlePage] and [TitleDetailsPage]. +class HomePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + // When creating an application with features like a footer, please return childNavigator in the buildPage method. + return childNavigator ?? const SizedBox.shrink(); + } +} + +/// [TitlePage] is a StandardPage representing the [HomePage] tab in an application with a tabbed footer. +class TitlePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return AppTab( + appBar: AppBar( + title: Text(l(context, 'pages.tab.home.title')), + automaticallyImplyLeading: false, + leading: const StandardPageBackButton(), + ), + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return Text(l(context, 'pages.tab.home.body')); + }, + ), + ), + TextButton( + child: Text(l(context, 'pages.tab.title_details.title')), + onPressed: () { + context.go(null); + }, + ), + ], + ), + ); + } +} + +/// [TitleDetailsPage] is a StandardPage that serves as a child of [TitlePage] in the [HomePage] tab of an application with a tabbed footer. +class TitleDetailsPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return AppTab( + appBar: AppBar( + title: Text(l(context, 'pages.tab.title_details.title')), + automaticallyImplyLeading: false, + leading: const StandardPageBackButton(), + ), + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return Text( + l(context, 'pages.tab.title_details.body'), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/my_page.dart b/packages/patapata_example_app/lib/src/pages/my_page.dart new file mode 100644 index 0000000..157aa64 --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/my_page.dart @@ -0,0 +1,46 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_example_app/src/widgets/app_tab.dart'; + +/// [MyPage] is the parent StandardPage for applications with a tabbed footer. +/// This MyPage has one child StandardPage named [MyFavoritePage]. +class MyPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + // When creating an application with features like a footer, please return childNavigator in the buildPage method. + return childNavigator ?? const SizedBox.shrink(); + } +} + +/// [MyPage] is a StandardPage representing the [MyFavoritePage] tab in an application with a tabbed footer. +class MyFavoritePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return AppTab( + appBar: AppBar( + title: Text(l(context, 'pages.tab.my_page.title')), + automaticallyImplyLeading: false, + leading: const StandardPageBackButton(), + ), + body: ListView( + children: [ + Center( + child: Builder( + builder: (context) { + return Text( + l(context, 'pages.tab.my_page.body'), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/screen_layout_example_page.dart b/packages/patapata_example_app/lib/src/pages/screen_layout_example_page.dart new file mode 100644 index 0000000..c3dd288 --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/screen_layout_example_page.dart @@ -0,0 +1,143 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +class ScreenLayoutExamplePage extends StandardPage { + final _breakpoints = const ScreenLayoutBreakpoints( + portraitStandardBreakpoint: 375.0, + portraitConstrainedWidth: double.infinity, + landscapeStandardBreakpoint: 375.0, + landscapeConstrainedWidth: double.infinity, + maxScale: 1.2, + ); + + String createSampleDescription(double width) { + String tDescription = + l(context, 'pages.screen_layout_example.base_description_before'); + + if (width == 375) { + tDescription += l( + context, + 'pages.screen_layout_example.description_case_equal', + {'width': widget}, + ); + } else if (width > 375) { + tDescription += l( + context, + 'pages.screen_layout_example.description_case_over', + {'width': widget}, + ); + } else { + tDescription += l( + context, + 'pages.screen_layout_example.description_case_other', + {'width': widget}, + ); + } + + tDescription += + l(context, 'pages.screen_layout_example.base_description_before'); + + return tDescription; + } + + @override + Widget buildPage(BuildContext context) { + Widget fCreateSampleChild() { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 37.5, + height: 300, + child: ColoredBox( + color: Colors.blue.shade100, + child: const Center(child: Text('37.5')), + ), + ), + SizedBox( + width: 300, + height: 300, + child: ColoredBox( + color: Colors.deepOrange.shade100, + child: const Center(child: Text('w300 x h300')), + ), + ), + SizedBox( + width: 37.5, + height: 300, + child: ColoredBox( + color: Colors.blue.shade100, + child: const Center(child: Text('37.5')), + ), + ), + ], + ); + } + + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.screen_layout_example.title')), + ), + body: Center( + child: ListView( + children: [ + Center( + child: Text(l(context, 'pages.screen_layout_example.body')), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Text( + createSampleDescription(MediaQuery.of(context).size.width), + ), + ), + Center( + child: Text( + l(context, 'pages.screen_layout_example.sample_a'), + style: const TextStyle(fontSize: 24), + ), + ), + fCreateSampleChild(), + const SizedBox(height: 32), + Center( + child: Text( + l(context, 'pages.screen_layout_example.sample_b'), + style: const TextStyle(fontSize: 24), + ), + ), + ScreenLayout( + breakpoints: _breakpoints, + child: fCreateSampleChild(), + ), + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.all(8), + child: + Text(l(context, 'pages.screen_layout_example.description')), + ), + Center( + child: ScreenLayout( + breakpoints: _breakpoints.copyWith( + portraitConstrainedWidth: 200, + ), + child: fCreateSampleChild(), + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Text( + l(context, 'pages.screen_layout_example.description_example'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/splash_page.dart b/packages/patapata_example_app/lib/src/pages/splash_page.dart new file mode 100644 index 0000000..e167e31 --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/splash_page.dart @@ -0,0 +1,19 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +/// This is the page displayed after the app is launched. It shows the Flutter logo. +class SplashPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return const Center( + child: FlutterLogo( + size: 128, + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/standard_page_example_page.dart b/packages/patapata_example_app/lib/src/pages/standard_page_example_page.dart new file mode 100644 index 0000000..59bd5ab --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/standard_page_example_page.dart @@ -0,0 +1,161 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../page_data.dart'; + +/// The simplest example of StandardPage without holding any PageData. +class StandardPageExamplePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.standard_page.title')), + ), + body: ListView( + children: [ + Center( + child: Text(l(context, 'pages.standard_page.body')), + ), + TextButton( + onPressed: () { + context.go(PageData()); + }, + child: Text( + l(context, 'pages.standard_page.go_to_next_standard_page')), + ), + TextButton( + onPressed: () { + context.go(null); + }, + child: Text( + l(context, 'pages.standard_page.go_to_custom_standard_page')), + ), + TextButton( + onPressed: () { + context.go( + ChangeListenableNumber(), + ); + }, + child: Text(l(context, 'pages.standard_page.go_to_page_data')), + ), + ], + ), + ); + } +} + +/// An example of a StandardPage that holds PageData. +/// To create a StandardPage with PageData, specify the type of PageData as the type argument for StandardPage. +/// To access members of this PageData, you can use syntax like pageData.hello. +class HasDataPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.standard_page.title')), + ), + body: ListView( + children: [ + Center( + child: Text(l( + context, + 'pages.standard_page.page_data_value', + // You can access members of PageData like this. + {'prefix': pageData.hello})), + ), + TextButton( + onPressed: () { + // You can change the value of PageData like this. + pageData = PageData(hello: 'change hi!'); + setState(() {}); + }, + child: Text(l(context, 'pages.standard_page.change_page_data')), + ), + ], + ), + ); + } +} + +/// A page to display a customized StandardPage. +class CustomStandardPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.standard_page.title')), + ), + body: ListView( + children: [ + Center( + child: Text(l(context, 'pages.standard_page.body')), + ), + ], + ), + ); + } +} + +/// Example of manipulating or modifying values in PageData and retrieving values through a Provider in StandardPage. +class ChangeListenablePage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.standard_page.title')), + ), + body: ListView( + children: [ + Center( + child: Text(l(context, 'pages.standard_page.body')), + ), + Center( + // Observe changes to the value of CountData set in MultiProvider within routableBuilder. + child: Text(l(context, 'pages.standard_page.page_data_count', + {'prefix': context.watch().count})), + ), + TextButton( + onPressed: () { + // An example of changing the type of pageData to another class that extends ChangeNotifier. + if (pageData is ChangeListenableBool) { + pageData = ChangeListenableNumber(); + } else { + pageData = ChangeListenableBool(); + } + setState(() {}); + }, + child: + Text(l(context, 'pages.standard_page.change_page_data_type')), + ), + if (pageData is ChangeListenableBool) + Center( + child: Text(l( + context, + 'pages.standard_page.change_page_data_result', + {'prefix': context.watch().data})), + ), + if (pageData is ChangeListenableNumber) + Center( + child: Text(l( + context, + 'pages.standard_page.change_page_data_result', + {'prefix': context.watch().data})), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + context.read().increment(); + }, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/pages/top_page.dart b/packages/patapata_example_app/lib/src/pages/top_page.dart new file mode 100644 index 0000000..1536065 --- /dev/null +++ b/packages/patapata_example_app/lib/src/pages/top_page.dart @@ -0,0 +1,71 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_example_app/src/pages/device_and_package_info_page.dart'; +import 'package:patapata_example_app/src/pages/error_page.dart'; +import 'package:patapata_example_app/src/pages/standard_page_example_page.dart'; + +import 'config_page.dart'; +import 'screen_layout_example_page.dart'; +import 'home_page.dart'; + +/// Material version of the TopPage. +/// You can navigate to a sample screen demonstrating the features of Patapata from this pages. +class TopPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(l(context, 'pages.top.title')), + ), + body: ListView( + children: [ + Center( + child: Text(l(context, 'pages.top.body')), + ), + TextButton( + onPressed: () { + context.go(null); + }, + child: Text(l(context, 'pages.top.go_to_config')), + ), + TextButton( + onPressed: () { + context.go(null); + }, + child: Text(l(context, 'pages.top.go_to_screen_layout')), + ), + TextButton( + onPressed: () { + context.go(null); + }, + child: Text(l(context, 'pages.top.go_to_standard_page')), + ), + TextButton( + onPressed: () { + context.go(null); + }, + child: Text(l(context, 'pages.top.go_to_device_and_pakage_info')), + ), + TextButton( + onPressed: () { + context.go(null); + }, + child: Text(l(context, 'pages.top.go_to_error')), + ), + TextButton( + onPressed: () { + context.go(null); + }, + child: Text(l(context, 'pages.top.go_to_tab')), + ), + ], + ), + ); + } +} diff --git a/packages/patapata_example_app/lib/src/startup.dart b/packages/patapata_example_app/lib/src/startup.dart new file mode 100644 index 0000000..f7fa442 --- /dev/null +++ b/packages/patapata_example_app/lib/src/startup.dart @@ -0,0 +1,53 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:patapata_core/patapata_core.dart'; + +import 'pages/agreement_page.dart'; + +class StartupStateCheckVersion extends StartupState { + StartupStateCheckVersion(StartupSequence startupSequence) + : super(startupSequence); + + @override + Future process(Object? data) async { + // Check the version here + // If the version is not supported, throw an exception + // and the app will show the error page. + // If the version is supported, transition to the next state. + + return Future.delayed(const Duration(milliseconds: 1000)); + } +} + +class StartupStateAgreements extends StartupState { + /// The version of the agreement. + /// This should be incremented when the agreement changes. + static const kVersion = '1'; + + // static const _kAgreementVersionKey = 'agreementVersion'; + + StartupStateAgreements(StartupSequence startupSequence) + : super(startupSequence); + + @override + Future process(Object? data) async { + // Check if the user has agreed to the agreement. + // If the user has agreed, return. + // if (getApp().localConfig.getString(_kAgreementVersionKey) == kVersion) { + // return; + // } + + await navigateToPage(AgreementPage, (result) {}); + + // Show the agreement page here. + // If the user agrees, call pageData(null); + // If the user does not agree, call getApp().startupSequence?.resetMachine(); + // which will reset the startup sequence. + // if (await navigateToPage(AgreementPage, (result) {})) { + // await getApp().localConfig.setString(_kAgreementVersionKey, kVersion); + // } + } +} diff --git a/packages/patapata_example_app/lib/src/widgets/app_tab.dart b/packages/patapata_example_app/lib/src/widgets/app_tab.dart new file mode 100644 index 0000000..0ffd88f --- /dev/null +++ b/packages/patapata_example_app/lib/src/widgets/app_tab.dart @@ -0,0 +1,66 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; + +import '../pages/home_page.dart'; +import '../pages/my_page.dart'; + +/// A sample StandardApp for Material with a widget displayed in a tab. +class AppTab extends StatelessWidget { + const AppTab({ + Key? key, + required this.body, + this.appBar, + }) : super(key: key); + + final Widget body; + final PreferredSizeWidget? appBar; + + @override + Widget build(BuildContext context) { + var tInterfacePage = + ModalRoute.of(context)!.settings as StandardPageInterface; + var tType = tInterfacePage.factoryObject.parentPageType; + int tIndex = 0; + if (tType == HomePage) { + tIndex = 0; + } else if (tType == MyPage) { + tIndex = 1; + } + + return Scaffold( + body: body, + appBar: appBar, + bottomNavigationBar: BottomNavigationBar( + onTap: (index) { + switch (index) { + case 0: + context.go(null); + break; + case 1: + context.go(null); + break; + default: + break; + } + }, + currentIndex: tIndex, + selectedItemColor: Colors.red, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.home), + label: l(context, 'pages.tab.home.title')), + BottomNavigationBarItem( + icon: const Icon(Icons.favorite), + label: l(context, 'pages.tab.my_page.title')), + ], + type: BottomNavigationBarType.fixed, + ), + ); + } +} diff --git a/packages/patapata_example_app/linux/.gitignore b/packages/patapata_example_app/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/packages/patapata_example_app/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/packages/patapata_example_app/linux/CMakeLists.txt b/packages/patapata_example_app/linux/CMakeLists.txt new file mode 100644 index 0000000..895fd7d --- /dev/null +++ b/packages/patapata_example_app/linux/CMakeLists.txt @@ -0,0 +1,139 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "patapata_example_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.patapata_example_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/patapata_example_app/linux/flutter/CMakeLists.txt b/packages/patapata_example_app/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/packages/patapata_example_app/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/patapata_example_app/linux/flutter/generated_plugin_registrant.cc b/packages/patapata_example_app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/packages/patapata_example_app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/packages/patapata_example_app/linux/flutter/generated_plugin_registrant.h b/packages/patapata_example_app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/packages/patapata_example_app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/patapata_example_app/linux/flutter/generated_plugins.cmake b/packages/patapata_example_app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/packages/patapata_example_app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/patapata_example_app/linux/main.cc b/packages/patapata_example_app/linux/main.cc new file mode 100644 index 0000000..800a5ef --- /dev/null +++ b/packages/patapata_example_app/linux/main.cc @@ -0,0 +1,11 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/patapata_example_app/linux/my_application.cc b/packages/patapata_example_app/linux/my_application.cc new file mode 100644 index 0000000..b5b2ec1 --- /dev/null +++ b/packages/patapata_example_app/linux/my_application.cc @@ -0,0 +1,109 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "patapata_example_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "patapata_example_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/packages/patapata_example_app/linux/my_application.h b/packages/patapata_example_app/linux/my_application.h new file mode 100644 index 0000000..5dc9ce0 --- /dev/null +++ b/packages/patapata_example_app/linux/my_application.h @@ -0,0 +1,23 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/patapata_example_app/macos/.gitignore b/packages/patapata_example_app/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/packages/patapata_example_app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/patapata_example_app/macos/Flutter/Flutter-Debug.xcconfig b/packages/patapata_example_app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/packages/patapata_example_app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/patapata_example_app/macos/Flutter/Flutter-Release.xcconfig b/packages/patapata_example_app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/packages/patapata_example_app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/patapata_example_app/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/patapata_example_app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..6eb1472 --- /dev/null +++ b/packages/patapata_example_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import connectivity_plus +import device_info_plus +import flutter_local_notifications +import package_info_plus +import patapata_core + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PatapataCorePlugin.register(with: registry.registrar(forPlugin: "PatapataCorePlugin")) +} diff --git a/packages/patapata_example_app/macos/Podfile b/packages/patapata_example_app/macos/Podfile new file mode 100644 index 0000000..c795730 --- /dev/null +++ b/packages/patapata_example_app/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/patapata_example_app/macos/Runner.xcodeproj/project.pbxproj b/packages/patapata_example_app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0352351 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,695 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* patapata_example_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "patapata_example_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* patapata_example_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* patapata_example_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/patapata_example_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/patapata_example_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/patapata_example_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/patapata_example_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/patapata_example_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/patapata_example_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/patapata_example_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/patapata_example_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/patapata_example_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/patapata_example_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..059d4f1 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_example_app/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/patapata_example_app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/patapata_example_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/patapata_example_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/patapata_example_app/macos/Runner/AppDelegate.swift b/packages/patapata_example_app/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..8ee8c00 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/AppDelegate.swift @@ -0,0 +1,14 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..82b6f9d9a33e198f5747104729e1fcef999772a5 GIT binary patch literal 102994 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ⅅ8MglG&ER{i5lN315cO?EdHNLrg? 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 z zKpxF`G*X(YOz2^@7i#D+s-~A1E;3&x%%qL5hkiy^JhYjJ74{hvVmAx*6BH`M`!qGC zO9pjEsR)A-n1`6KLACSL%FS_Kcm+?4*z-V?WAZPs?RkzoijIr~I+oh1^~T`q^dCFvG$Gbd8AnTYBjLKYUmayaQz#S1le7Q^Hyr#;X&h*1wDpm+gZC!rSKom zq|+o&UGpeXtlQ1;?@JukKG!8PGS1Io0z6O}ZeL&DsON^I0K+>Mxv#ohK+;ByAZ`Eb z2orY{j0Pa3edA(#-pJA0AaJ6h& z81Gl(pd#j~mrizktoid14K5ig7u8FvZmLLP%l@dl05IprCyqDB?mA2fc*6UB+49lb zZ8`V9epdo=OeZoiY%zw-w`8DNwTORV_>>3T{r)1-YsGSo0E2s>tix9OBqKFBjg#}G z`pgkCblKMYs!Z)r^(qT_c+}gLhR|gnq!1~Qr|~kt&2@_yswx{i$KEn`8J1W8BGljl zr@GEG#W(s#AKKyuqLp+cl1C}7%`m#-!$15XF{M(M*-fD%+i#mFbP35jlgN3{8#A-dmj&OQtG)!031jTwGMal=&YtPfq2AUWekP9J-JT(p099!L`+yen$ zVH1?kRrhV7(mGKkm_jPP_U@Xd;x=ppk}4WY0Rbr> z0MJM_;$GGxL*P68y%KBqHntF{>X&<{aeI4m6+{TQ%~Zp}v%Pujr)zg5mV;cFKqeA- zQm5`#Sd{B6Rc*4PS-rO(vf>YEdXmOK?>K@`L5}|9q}#t_IE%g+U<-1qw3mr5&v;2A zCQ}BEn9_u;;>n5N#dP0RhCF-_UplC+U(i~Zjh>U5+b8%@p3HK(R*IMQwE!uritb}< zF)AK2?+0@-aE3LYkg`B*&N&m~JWB9>(Z>`aqRwgioU)0w{U1K4?>-#i|ZfhNa9hV)2)(%ch zJMH1twoeZWwkE@I!dz$ma+;9GeACv>Ncupl@+gBSeU_uzfj!$+h&@EACkZG_vwLGA z(?^;rcJu1$5H~xI@6lHIYC-$+b&hF1p`AoAOKqw{t0Fu#X`OGt$)7Q!nmJ=&)xjq@ zHoxT4pcYKSPT5(4yzIuQ^S*N2NJpR4v0?rB-^JuaXNLis?E(l>Jo8mUw(gsFLLOy? zEszHWGaCn|lw$LSwoj{G7Uq(zK0W^VVWu#ms8BMRlF2z%-g`fOXmndgC(na8fc)s` zz$GAoxP+l|+T_S4$r1sLwkV77ew1Gug*`|HiE*?FGLm1q; z^p0A0eqqbmk3?|!CB9DBN1Zof6d7+ zJSn!`VD~tVaqy<*Mw^8dM5v3Bvj2VdVFb=)U3L2eDM3@>n(P z?Rr_=I17+r4fE{>1LBQG0&o97nef67n-aNnVP<{dd6*B!Q344 zZbsAof&jw+;CLeK2d87t9s~YZ5?6Qwf&{NPEBN+)LbjOcZRXNcR&h)x`TtdpI+b!>$E~h0o1L*2OddpR9!Gw~-E^Cj(7i69S<66ak$)AYMv|xG+;uR(`;h zGIV3}?+Qxdjz)s;s}jHY{JPmeo@-tN$H@hxaV@)}K?y~ts~E6H(F|SlsN5oH8g7*h zGiC!8c1doE3U|D}Vul1yPmXuCk*hmyU4MG2ml#V0+(G5I+`L_=3cD$%$I=@*8m-LU-!fn&-sZO1%ls63+w}AiAK`Jv z>`q~ztr&&(gCkFpci+*1Ekdv*MhBCzGfPBj9dM|YEjZk(tWBuz4?MGeq+*)t>Q=z6UXF_w z{QDUT4^JQ8J%hW;d2xGB>Fl4Y-bRT!ttP2GE5jYoI1e(eVK0&V5W+>zludt=nf|UN zi1IV;MK$Fy%$yw<oGeW?JIGjmfGLH$Y;l|T0p1V!N*Jvu zHSAG0WpwPip0vm7%VRq8$2O2>P5b!WBfTz*6dZ4Wd6O9Y(8A;nOuG((y?F`ac_u2( z#~17CoTK)1G<~~Z4jXlout{e&nZbDHyHf(=a?OtaJ(2Q(!g#)Ugw-QQ?A?mN#yN%T zBtJ`sA6Lpg`k>Pi8a7GssiY$eG0Be8LCoQL{GDqi-;j0pLmT!Z)szldvbN7GVcu*S zzb1rEq|M)1qa7rM*I8!<#w7FnQ?{v^? z0`MlS3+`#ZB5$DT4+`7e-Hlp_2G0`*F@STbRJ|!tk3cC~1T%NR-p4s=sTT+RqsMjF zyrp-Jv?CD4Y3N&Zb1gr=%`MFR8;|r)uxQ6*X{OpEhQ~+tu}^n8Wijiy`pSMw0uKNi zSNX^Z1y;WirM0o_x%zft0U2GcLm_2BS`b{Z>g|9VOVr%QF*R?pTpiJsEbj4jLVAyd zTA;x15=f~b0^(e*Vo;Tn;WTJSxpI9LmL($Lxob<^S!k7mGhnnVNnAC*g!$ms0#Q|q zs=25I0<>fUw_&+KU`}5P9wlmjRWdMYh%Np6n?AAHQ;JzG?s(Z9UR`pNh79Nzk~DF+ zX~jy>>f-2bl?drlM8 z3NfIQnrT@pLmv+QA6efWPv!sqe;mh3_RcOj5>Ya;4hhN13dtx*_TJ-=kX_kZQDkPz zIw}#e_dK%au@1*L&iUP^cfH?zf1iK)tHv=t|>-9mMT!;;Vg|svSzWkN7q#t$c4N$Q;tl3EYwef_4q>GO<#I89VhY;`X*hz$n*GZ%f+;uViG z?uLlxD1OIeid}0r9%Ssoc7@vJjZIsZlU9zvYpjhYiOrzD5sq3OC zpf-X;Nb!DLpxqX^zDIK%=46-Z3%i-bac`RIBS5*wcw5Pu>G|kF>TQP$dGRYh#1hwD z{|cbbTOKL>Gb1-;X6?vWLC+KJ_^Ij?KzJ7eZ?^8XNgoYU9^z&>d zsIjX*uOK`#Wu!`>L@y!=XpQcW+mBaRjm|XrB@etLdr}Ob57e7EkE;7a*t7=M#XFL6 za;KHHk-rBNTjp-gS^;ehKNv>K>+_jPQ45J%4><1HyKJ?;T9#~k_23?xD}B&@Wp{%H z($hU+nWR?g!9dsJkgVz(J_Yrdns+m~9V_gQ7Sb`&F4wZZ!k}##j$>O{4{?avCbCZfyW zO$)m7LE=P?$CXHDU_RUD+sYwT;nKI7 zSs_XTv!BuxpJ!7(b~uYfsgzt~mj5(vf2r~`LHwpePs!o2A3zEr@#sxo8HEe8>V||d zBiz0@e&6}p*}!6jsm}I0bN9Mc2(c#jg@;Nu6!Kv&4&P8-UcQ-00WJIO%4OuUn;^jU z;I3r=T3KQtiMQ7&x32eVtB`mCe)9ws^7u%2P`B%Xc}=Qc&O^{FmS^{~Rho}^s`B+H z=1_T);9LRK?{$Vx22!5m)Er8aoPOA8&{7fyt`t@~Vw%gtx~+g3qs8LFR%(2Uny28A6dFYnNQgcUa>Sq=%alFh&8#@1o_qgwve* zVFimnUtL{4aHP6s?FB%bu2SP=e*VGqXC8iuZ-JOc{5%Lx0g|VvyWkdh&FD^Gkc!0N zhoolXvp6GC8wj?Y+V;r*EN+<1ac`-+!8Mqb@Nz)=OqV?4gxhR^t7*+^+AfxxVt(n{ z+fkk|-xSGqmkZa@Q%`;;r`-Z|? z0fR6b@l%pTwK*@xY+(MwBUwf^z+F*~piC64BWTrz}-HS1-XF-IA%?Zs_#F8 zcmUuEZ6Of>YIJOe$&{V;3vIBw7|jSGPeS6cvTMdj96Y~pI-z7InGW;(DhFqaiTTO9@KWvQi9__j0btLZ9 zAa~-Po%^sDFfme4@Yiq}r`BgnYK2eTwCjg9_zC4V{{&_GTm-!qHGVR6JXDjw;}GzF z6lXA{xo1+tQM{9vwb1&sRXPdGDHbEMbnwh}t+%tvcw5p4J4r#hEpDl=A{;Mjc%0)T zsG}v<$^HhdcE)5IJ^iBWK{7?Zn)vb%c!5eIj4 zbT}CGO*u)Od@^LuIC@_2{=AP2-O99NglFudj{!T}0e8wtTQcB@F9QW6$J!0Ye`T+U zXDx84b$!hD#4YzSyZLy~!IIZuFa3%eU zG4eg5?}sZ6Yj29P^-PcXG*8%VzLL$0!oL?c(!oQ+G!kORsa+lsf5YER>PX83R4LgF zgPNQJ#Bo#)MXU%J9k?RWD;c>|as5b5p>xAwau=X5XbERX`_ZHB8_XSNDe`s?n(e>) zGF$G%n6o+W{6A-@4hsIK0*J%jpB#Y*G^B48eQD(CDZR5oBl-P=)r7fH^PLf?!aK6V zwkIM35?l*I6p@;^H}JIDNs-fF*IFN?k?kj(M)QKM%%?dSkf1d$Nly2z(>)oq8z}0H zH?Qa{x&36#W@y04!9zx@x7un@ob$&)V8#f~0n1|jF0kFs4aZ{ND1~QjWHToIY5)LY zrgKDCj@dFCx&-w$QMi=CqD*=`$NqC~2k366pPXl#>Y7A=iQD}f`)+B-pS@LIW_M?9 zlBS_)(vGz!L$#P`?<3Hvonw@B1uJ244y)M?0)z0-hq++sJ0GZ+{oiiH;lFi&wy(C! z0Bv9z^M;`4@)USP)7dhg@K5K&U&|7&-@I0Sk>I+ZH75_xEn>qh9qmc%aA@NEKBsVBgUuK zC=b{w-0oU|)~tAVI zyJ3BAB}%rsjz7qZ?x_XCWe6!_u-{e_3u68Asso0IvwKdxq1lN#%4w>J zi>}P;$JZ>58(ZAjsmSJl6BWUTe`0eGEf3f_yS#H6vx;UJWO7CCK!{)4C}`C$j5gNj|k znb$4QRurEE3tPEe!JzG-a0DmvXePO zSD#Q-qOAjTMm|=aBSnvwHoEbgyVIz@J$hT*legak-hhb}e#%cm2$nR2 zV9A{kc)WT$np=5coPQIskbGMO@Fn2NxPv$@SJZdG6}jV;+%(cH+*RFQ(+DjsJlman zy`D(yN?8MCtjWD3w}Q|jQccb$}BDW%M$zZZnri2+5ls)@@(wQD`jt_GpTKL_^CO&SSCcHbfMX#JXYFI^*947 zPh&S-G=l*C@`E5CU1$m7ao(Q&oSmY7)ZZ#5_fEyYzLsFJwJ%GfErFeRN@7lUbUrL| z$6;gQSNsI91LJvT+$Zb0>g<4g8T{B!U05lfKmoSRH^pB^^8sJ3{8PzVq0NeypMF5k zU3qOqksdq{>AUjm3O~dZx^vS6C$ldgCWszl?xd8-sJ;-kPnISB*-f=L*8XggOx$?u zg%B-QovSjBbj}%sShZv~r?`*6PiiQW;nee<-=+y4}S#}q_BgXIJoSOf$YbE7vXt4;Np zrKzZf6Ny0aES8(-cqmnIGMg&ieYWryBZ0VTB=4<*@auP4NdIk&q(Mt(OLPm|Yl za!0OpC9sA#tk>OsaCSx0;!$5r6naw ztzLBo>#LKaxxsO=yWe%yGilL`A|6E#TK! z+1VRQlo*D?(k0-mlRM+`OMT8kVB*-%ZGv}Aj1u^j!wu*~>L<-T+u?6sX!3C}lQte- zk(6_=iwXsQ0JbRvJDwMnk!c99w~s~uD_4vMB=m~-ft-*|z~$*g4g;pgG~Ap1m@@Fx zWS)8IKSN6`^vVQ8hv^Oc+O(Rt7!U%wVsGP+Y6fyS%GG+v+dIdVfCXPzAV~~li+3m5 ztFQmbE)(#2#Oi@k$1#zUS6ijD_yYsa{+BHZAw+^zAEI3bc(h0qm?|pNf?oS}Km#OG zrOfCKn_-CVO;}DXu|5YE#d8I2o>}vUxYlv&>=+I28WY>a1;uI)HUM_IvpF;Ln4ROT zf!=1rpKihNFUo=R@sD-pT!EOm%%ncl43f;aem^;|A#s3`b6vjeAzO!M-gwc`-Kj~{ zBX)tq64*kJl#TrgW4o%hTY3x$P01nD6a6s2#MmwM$vyX5PU|YngU*wXGK*?f?#Eg$~^OWW3I@of-=XVuu-b%A1Z|nqY_2 z;~jD&=QnB#WGU>;RwFq(I< z34K1fCMwf9F}G%k(&?~2EY&)W*-_z0ReS$;7+I1)zz`)M zpAF{5ZHLPMJhYU z;GE*@hM1NM{G{L94dL$!Y-h6A9K9W=I6AYb`Y=v{(tpyLQz^^Aibea(q()R*TU|-m zozpyr!|-BZ_Dn+$*2|vq2Y@ghHo!-`WjVtU-bab(SJp2*2i-}$UP9^qnF_OIFS~-< zYj^VS!)Wu}vn6!LDIt!HJ1SU-@ce>z8f4cT4R9V@O^Xg9)4`VpjsXm*~@%l^Ux;Rf#Zck`BNXu0Y(!C zj%Z}UAmD00nsOS%Uull)dU(fZgJ$bo>3Oa`8h~Wt)EM?v(ndlTS1p0|E9Pg>=&>58 zghD~%R;YpqZAw;F;M(lx5b_wkVbnd+ER+6A-SYj^1XUgNGn0I~ES|f|5emjyPIW)S z0z8i6)BZt&h(qQxih4HbFYa6~jyeKbc_`QEdLD@9SBGButjw|b^l*oQjDk<7Nig08IK zb`ATVGzK%LP+>9aFM0hr8t+m`uNr?h&8o3Rp$T&ql||K}7GgobFhCViaDH~+F#yC- zt>7T3&_PZ*feTKTyd6vlF~JmEA1f+*>CCE4ex}5N^$4o)YuxX&3T$P0(IS!+kan^J z_p>v#1J8bWELml|S02YAQe-&yVew+kipZr~H-I@yc$=8#rZ-8L<_nDx&Qv3dJDwUX z!)@=h1`~R2M{$J8bM^1O&Gy2oxe1T;K?NA{iv_eYuhpLyc3%xu%z`dVc}Z}%cHGHQ<7P!Q|e?dwnSpL!AUf!B^!?#^Q#W!Ry+7ofwPZ1mZq z(Id0{htmX1W?2cAYWZo_lOtT#+Us-nlP$=CGK|Ri4x0Xh>(|iN9y1 z=9y26A4Y}ViRi9Fxzm{>J`YM>GX1D|$4BY9xJrY{oY2~Z&};B{Zq9Pp!pox`8e#0C z-h~@fohA74(#ws!{7kIe4v6XUX<)9bd)g66Bz%^Y4p0~OF+rY;l$v&7T<3~4y!bv> zR$r#LblZcVgy2lq!ff+>yuR4qCcljQa03x|dTcG7`CHcxh#POtGKt6ymNd_0qF7Wf zBj_KC8{jl!zZ>0neDp19n3sD?HC=|WM3!}cK4zCnu6Uoj*hbV1<#F2BD)@A~y%@VXx+u}Hcn=_s-({PxzmMZ^xJ1SV zoZMY*FarYvO_@z8Lr2ep)%HgIL7rhYa~#X&&V8oYSw zA4m{3{hw1Vb~~26K^xro&e7i9eg^SqK0i}kG3z(!_~E?sjJlSWIWXJqKiHAWTG*SpPcCMD`kEc1gx`R^YkYWz zEN4vEIkj@&e4tC!(_~x`-K$w6CU%X7U2Y z)Y}T5stEyoSsB{H{+xfST3tov~6@lO}2gx#N(rHXiOAHT!dp6FiV8V)B4{L_P_% zmX0rPa^-{1xG6|#uEGo+!v)QAOjRe|jg2ICcXU!|Cr+LMbLHlhJ)ErR*P9*z$NLlt zmYjAUbljq004ZyOco?HJovV7M*Wb2nF8vT2D;3kGi%F)6Kr#TVW>}zTHnUQxoGmD0CY9J`|d%8@}n;_co2q zWr98`R_c@PQbMi}x3bWo4XZj{it6qYj+o*XvNoS4>rF;7WNn;vA*|A!3H}Wh-uk@n z*hV0S+XnX;K;BOoz?&*9_{NnM25s4^^QUt|>R!()^Z6#G3OmL{CU^-IG_M7_a~B+& zCrV;ouC1ljbK(K=ygqAE_-}ewnH2&&t0enS7}I4i0wJgNvCf|P$`|DHku`K`HfDa2=n@DCg8MRi_)vpMR2Mxy4PE2Qe! zD||kNXy=0WeU(43v%md9Hg9Zu#CP%d%C67gk_#pfXs8lf>M=betm(}0fdDKq0{26# z_c?J!Cgo-~*=wswLXkR|W8d+rDdV00`22Ouv=_Hod9bmB!=D$I4r@7DZX7e+0tO!9 zR{0d}A6^K#yRx@ykotO4(WUJsmFvN)d-o-wZ(wcDSUS`8jO-JSAMa4y@MK4fDP`(P zzxQ2})ofiauWKj9{Rm$Yw^?g=?`oO(Vf|T^I+-A+o1#F`>tn59d=FtgVJAV=y;G&` z0GMvtEeil5;e$Ln8-41(UeMl2kYLk%vPl?0+Egg_;g)494o5FsvdeZKP;&&fjw7o{ z|B+e%Z|)8Ts?=>@p|hr!nYXgV=ZjI4Cp#$E>+g^6r7Nd3<>-t=G%B5IyZUI{e{49G zqnIXEB=M@5Ndf1J#l5YWcLG=A4ufF8S{z5Kz-uM?Ni{{%mr);=l0=473h#cIc{K3> zZ-VUw_Ng5^HgWQhs5tQU@qv-YBej9`R$a^|lknX<*+sSVXue8M0#EPBJ6_Liwl*8l z_zoD#!l%WIXJZ$jm?|zUu0LdeP&8IW*(|39&QzKGnem$6--u{ZGtHt#Hro*h)?lu zXGKo-4Hv1WP*VLj;uA6UwGSV*6ro%PRbwR{@tXoCOb=OFTB4ru-|Id!rP5Y6LF*-D zy|t0qDSVPo$ffyoj#CIZV?l3VsPRYye$F^xxv~Z78_fwlCWbwW!nYCR2nx0_+@tg3C_UDMVa2Br=X3hfP}^Cp4Yg=#OK}K zKYVY`V9jEKD!UrCbSX6Xym2T-cg}!n;?;o{mM|zWj0P@D|FO-rQ zKt#ApEh#AX%_f%9!G6`I*K=bSnMIhQ%W5&BOMntzVr*eS;WR;FgM)+k`#+Vze*z&V zkU^I-R|!Nwy<~>eeQ~hJqa2|DdpX15kD=6U73Du;T|VarycBP^n#IZeIJ&H3S9#@oec~poZELqX$DAc>XZyuIqd^GK0Jq~0kI=d zA7gMo8%zmkEdnqMh)tkp?V0I;Tm3`>aU3^~dXw zlhdd3=iygnUgYu#GRhxln}4D?Gokczq?T;RjCk0=fUHy18$lt!-q!%sNxee7No^+N$9d?Es*``)0UJ4SC&FNY0pf z_MlbGdUy$|F}YDvJ9GTCkZbsNKj3DL5;=BGBx8xI;n)=A0d0j6MP7Mi6MQdk@Tux2Qy`oI_&*%EQ0bE?|R>P$rDhcFa8O?JIK zPOpFDa?-L*+Q7RrCg#y5z$l0d>n@+OYo3g>-Z*x&`Jj5|=*UOYaJer6;FAbdtt0O? zrFGUE?!XeUG}G8wMgeTs%+r;3uUU;Nq5EuU{h-g&UOBKhdS`;J=m!~xn*ztv_p@dD zR)tR!P=~5kX)FRsx9)uyuu?0dh%Ht7`PTM@e#Cq!z2ts;O;L)tQ1ipDiWqbGz@o_p z^D=UKR#`S7HAt4vQtD(_SeWyj_av~#tJKlb9>-s5Ykuzx_E1ZNl4)~f=zG$*;-y=T z2ozmFva9az<{2&63fQ?(Q8{IPx@t1LuFcxP-LXVctWh3AwazVTt2)w^*Zn-#eB`bD zSHoAusjOBK5(>uQPGj=ijdOH3jqG?(<5#C{*JQ?Lt~@zow=Ii4Al$Vr!#+Cf-gx)A z`_h(>b@7?*6bYM8%628gGW^rwWoG$mK_eCk`}B&llStfwHf12*{5spmTeNH$4{gCY z@Yuwr*k@%m;T<60bw9z6^WpWi@Bu^qe-g;YAzI+VjgsuZaGA=^G*I{KLy@rIjSpWb zFQNsCp2T;S$VaJtZ<(waRu8y7^X;>YhsWp zM)mKgCeE@K;J4vQSV z&-(Gl5AJCp>K*2-`U|4i;u3p8xo6(isu-38>cY zml1Eo&FBBKJpour?}q&nggpFiGM%m+YX`ng8P+uRnJiMyWcv*_AZ8KAB$w;rfmN8C z<-2EB6TqZO>A~P{*<);wYqZgxQS8E*syOXvGkGxF@s(scud0uv?T)fQ z(DGrwM7lvpitUG~6!*}kZUpBn9PuP`5^nMK@($xI^0Q~axP5qU>L~uF{R_<9&m z({}$$WuD1y-QzMVb3jLPk`~bDJNkw(Dv-6cKUb4uzD= z-w?i0NZ2K}AbT}Zi^uOZ32xmSxJw+6(3j%a!~Tdy-@RxVx6YUw2|V6JX+mSJNclfl zF~SD#eo+lnB=ZpHLl{)E+`sI^-V1Vn!6#Ml_W4aH*Pe(++sNI`M=5L3?X1z0;CJeE zJiX5Mp6JH*=R9W0t(1@>>1y=lP^F=yJil6JxU~I}EpTsBx?rJ5LbCbQ zuLBmmX1MO&!E}khx=+#hCesIB53`IWwqyFtR{AUv7vJ{Q^dn1S0@*^UOmRwctFy&> zd={(J@avBzmu$MbyamRMt_$kfHY<*v)%%&nY4hUDH=$k)$8LHlUG0G3Kv#T~-vQjw z)hXbsNIg?~b-jRw)ir5Q(gfwM+Zk+0haf z+4ER%>T8RnKAoJ-(s&tu&-iZ@A?^J|d z6md=9C4am*v2r=aa&a?~37bc($n#wQ<8UGXL+!RtrRXGSj-2INJ#+3J=}e6nOC}G8 zN~lvCS@rxoq7w$CLg-wx!%V%ymw>~xhUw4cADX*$A}D~{21F$!Y61aHwpdL!QcrsN zl~$s5kk%7HWHkZ43%mOcwlk3RcbKGQ*}K(Fxput)rpE0zH0vY(EyY=blQZ`odG#hD z)~{&r6XkSE(^csqsaMm>2c%xsT2&g_Nab1bTY%fIoNHatDY@C@Ei~v@19|F?szU6SWRS)uDXqNY!48RlAb;S*ijqus; zp;bteR835>3BXML2CewOM<^q3M*ubU`}gnI-oS&(vf=GF|JJB-inGOH_dc1xb|iqR zWgrcNy?1*8)vAlAaiBE%K3Q>5Ygy-#Wf$>FqL|Kvgb&6H?iQC*Z|PN)xZJhH#d#=a z@s9O0oea6Lg}submzNZ{iZ*_okZ$6G*h5YO!dE=7c4=YA9g$y%1xjkVl#|1DShEjM zH3(sS?uRfB3mhW5Wrm} zrY>KpBxM&CC;s5Ie_{o}upN{vdb8x<_$5iiQN49`z`+Zz`&E`yLAim;X&}$HAfKmT zkO2Dgdno95mWMH~h2c4);H=MigT8hyzl|4g;dU7F;p^X>w!fa0zf{^rf?>~ z0w{=F_R}ru{g5i@&xwC%R-!-1x|(k6pSb5_)$f`zyErIvSCs{z`iVvU4x_znFKti!!av6BkRX_=+kEc;*`_rla zB`g4ruCJGT3XVTTrlh3Yj>1>PNIy?sV%Yo*=qaBIOY87_?P04yx6TV?_{~K? zOHEo3|2EA2JAMPYZM!H<{|!s-$r>l5{19icxV`Wf-{<0I>{v&H4FZaCy$B6Ludz{v zRH!!HV#JGP?5(L!Zp#}NlOODgWqjO+yo~+LasPYxH+ht2KjdfCFQr(oovP3?vkFK^5FvPJ4^LD=DpYQi4tUXuY1;erJaBQ79 zHcp(>mKvoD+)bq5SX9siR>(%CL??*D>Snn%p}NfGO4(RY^puLI+j$Pw)NZLb5bKo{s|0L~ z-A3R~;QHMg0bHSgESOM&N&@oF4|8gkPF-nVM=sQ;d}wcS{{!iW-)yQ``D6t#xlh(O zRF0Z@O>0uMz9g)u{P))ptV5lH2(gC8I5i(FDRG5Gp1bgBydKgxJy5gBfK(#D7NzZU zatG}S^z#KL*Do5=K*F7hk(`mbdgI1XoM!8*-};#UzNtEG@Nki#`7)GfV;VlfW^)=` zBaAjK5>gx@wf_D!B!2C6xBK^K4%x|+#?P@5N7tlfWo6xWJD~Wz^cnPfFF($Ixt4!j z9%x^1$on56XZB0Irm^kw-*rd1YVO;(*LbB21@7OPJspo%WO676#~oUMws(zP#+shG+$ns0IC3W z_{kYU>N5<_6=j>*0d}r-?8U+--eXfy2M+opoYL|=I932TMp=&k#tzJ^72OtRJ8BVOvTYPh;@EE=LJLeOk`y?d|Dd9%fWlhON^LnB^6x0LyZqz@imyogJ`$C@Lr9Z4o)ZQz>NCavG$$@e2#r3 z4I=}I5KgV>wl)~_Ja7gLQGju0c1{h%cV&6c`doWWv$>q*=ZLc8J{hBiKXNK?zx2Nr zz!pph;BLU2OaZTv>Pzj(VpSp2&OWNCF<~>NgL!nezhxEgj;&2 zl>z@V#>sykFCnFL?|(j)J3SFr|FFa`n@KbhC2pZB7 z#3>qIn&~mG_Vki=p8_x&CFeD4V7MvgJlk^G7H;(apFxr+7Gc0+1KfI6$@aeF+d7DJ~_-A|H=0?Da#&^Cqb=!=fVz>giW5nw=jWQBS%L^t1EZ@ zCm9;qlG{($@0W3T&l17ownc5pWhfM8Mwn-fLtb7H|IYl)8@QikEc_Le+s60x?&B*m z5kObB5{BD}gGr7l84~vP{N)C~3V;xhBWd%=^j0&KBw3T3-HU`;hqWA3OWW~<8nl-M zfYn-BI0_?g`3$_;&Exw<(G{QM|8)Kq28x9NF-F$>r@_BO)t^T*i-U1bX01<)zC_uE zR@8qEQQ#cm$YbXIUPVO?z7KI$pw@r=-V{V@>dC9Hn==1QBVy_b;#*jR+&f*$AwCl?o&G?2Uk4=*Ej zFK^Yvw*HTO9n!XRBWe++o3)4O!OC9PC=_l_<$M(W8(Akk`zv5?nJifb^rH3N?Hhio zo$=nNmSEz_QFHj|XF!vQEcdqPyZz_4|M_GBH)k)KA9XGRlTJD;3*y1c#?ZWkeaQM* z^`Bf04#Z)ARgrE4rMmlk8E5F=NpaW8xKNd3)-orW$m+kh(W12jQbQ7oi z)=#qbmhkplt}u`FC0sV9sdnb5$E!zX_xlA{4wW&j0*DCm`=1;Sh_sB1xiH@C89Z93;8d)EUk=lPNIZ`o3H`Vd+Ig`=CV}#?PAXvzWk{x96fn z0(rYh<>?PJ>Hd8v@c8=*vm+)>P1k@i2>yMaKw2nihLV6Z;wcdc*E2{8=xNh(FkEe3 zq_pc;ISw&}`?lqKx<4vIa67!xu|P}G$c3MDyg?u^InS?uM6Zzys0QM9ChW>g-ypzA zkOUSfvhTTWq{_>TJ{+kpgwX{@>P5ptiJ1NTO5)8 z8BiLUY_!*AJ$V386^TicK@z0qOPWP#Ea5?}!$_&fQ zOcRKuR^tLX*&CM(ahYftiNg!a=uU|He)2nU2(~iX@Yo|foZp906;o=d%aK09YEW7_ z-yX*;XE#z@?zZ&fQ?2fYX!T8@-$(K5Jo+AkyOM+(944x4B%2NR&avFFJY^9_br5UtzSX5@gmYYm@ z@S$jtqFn18bXQr0IYhQ=+2~ZDB_DRW3d=*B+3q`-*1P$i!GVIG(AMp=vBQ#^_mNxp z(;4Iz#_~&9jZ}}7oW?R;_x8&h?b0N326NJq4~>W^TeI^!o4=G5G{|9ff|`NN5+?ns zL@IWva(*@PXPmVGQ#rgIOY*nnoqNDDy$hd2uMT>wBgzg>YT&BV2U{k1ah1(1j_v0` z@o;6~SUGW=!+j!oa9ko_2^G75?VolPmWk=Pb-h{k=phZga( z88Rp7QzbHkpYG!aug9e^DF63Bi|1#CeAW^CpakO9DTT!p$yhuT8Aq10^cl2O@Zl-2RXr`+zCPj#_FqXs}W2{Qvn2Y{BmNsG45? zB{BF_rVgT$u0 zE8o6|@C>uOK1Ba}!V zx!M$9J1B7#_JSs90cKlucib?T&HqQpLE9YV1?v{gh2NWKEt9FX8;3DePnCL5Z=k)Flp=?-i$<5H4zc z`?2ZZ+p~Y8FYr;m3Vn2(u5Z`Av6#S}zkpQpZ|vNP0DY^I-oa$HXzg+ajQC7%wldRN zfOAL!UwFtuphqqR41v|3He4cQF5;UU9M~lti-k<HSTs^#>-Tf|C2&~#m%6WZAy1jz!Q_-IbpZP z8ht8}UG13lz+N-7+01+RlE)6OT^3px7fn@1|_b7^{bhPet}< z_)77(<^>8-qQ2X(n4faVhm@T0@Z{5HFSWs~EDXtV@7IAMbVUP6;v8^%l3PZ#wOZ-* z*Vk4lRj6OYpAZ_$*`t|tYKmLar&&{5{d+5cst)rQTn`n8>Xi+0zXc6YbTPMgzewFg z23F=+`8=FXXF6b*CDVN$v3|6iy;TSFSYh$qrbhKDcT^U9l zj}3g#zty{k*>s8S+>t|cng#3@Rz`z}njy{*?90mV6_Mkvv=iL9pb0ttHf$7;TxkX1 z-klTGb`2~-Mxx6~+{b-KiFd3XG`p?+6-0PMorB#Q@TY_CH5)En#5WrmHqj;@Fvi1A zeGpO@wuYIPOgRY&02e-U+j7!$LZ#5mS72R3MJS^gfheL5`kQV_n{8}KXaj)V%4b~As zFrQ7yZal}~{ELX@8c#V?2LlM@)g(|;VvcBjEuTJ=`WkOem{DL!+7Lr!U;F!mGm_^~ z+V^T?%bz+8noq9{ybcq16Gzd^fS2`skac)@6|;8X8l6Q19epZ@l^3@1ES!x2XLNA4 z_FI8#x5sq7hXVr83D;_5$sU!*Ye}zyx1wMC?Q{DSgrUx#fM?_Fj@{syA2x2yL^J{S zPPLkQ#O+9E9a^H*USdriL6rGHDt$B!vu~t7^)@_e=(<|SVd!MenX48AP(Z$4WoC9_ zeN;I;hEAr{ZvB^gK*1AWfI~5H0a{Y#2UBjn9`7;3JDrI5leeufemoZol*pDlVTSHP z3#8@6kxsJwUFg9(;)>Xm!{nsFC<7}Xwv_?o=eP)$>vvvj>yw z=YS7{pIOg(u@mJ%G0G^TM@L6>l)?_{_e`(yLxmX%h*D zMJS13@e!}HFR{?GNtq;%=4#zUgfFP^$g|Ax1<`vC&qIPbwGNo}3>ZM?=Evk6r|J&S zi$UD-za)A$kcqu)8)1mG z{FI*zS4{wM6S3;RP-!$0&8!6*;>|%T%HJxZt}cmap#~4vD0Pkx22gBbPo~=2iEMFa zSN<~qRz>jf54?e)>3%j;Gc6C1_YO0C|CDQDt7+bE({$0($tizZ)xn2L?@6_ zR3$`yiwH?E%X*^k*^oQ=z!1GA|E&fXHPR=rIEGq4%0=SGvror2Y%k#d`aPmx5@~7a zdkmPa1d-<`6M%& zp9rn|?C(5SRowEcasXoE$)s`=GvJk9wPt|2VX31T2F}6x3#(&IMqZND*a1muBh9?X zX_HSLo?$y$a;qFx^U1W|YAd%)Gaf|AEHqZ*{PW96FF*&nO-@c?c6t5=K_z@2f$8<^ zY}d|9NRviy7sF$61>@bV$B3*VeDg4DX3qScxVTL~5Go^T?}aG+th- z2`EduJx~ZcSssR;yX%oW&ze|$TF?;>HGHp~Eq?$w&SAD?d#s$$|4F@l*T7}X$7>}7 zRvPwxrPaLO5X-qYiQ7{P^4Ui2GDbq&DJ3Yu`)8zfMi1{>HEq`+uR1bJ4x!#n0D6_M8Zs_# z3mc%u30aK|avL-!XI&?{^%v4OXUr4OzaL*|-HV&M5GPx)SUqYMWw@Ex;%DHx^&FOD zncjYHD@AiYbGx1O(rsKW>Eg}cid)6bqA}!r!G{?x#)c?^k+q_uv%Xh3ha^A^{%wnpRPY({1LqK{NQy>!UjUc8f7x2` zgyLiGpsKlFO75ee2#drn3Glyna)PvUP}e(t6P z(8^W6g23+fzT5gZQQ^L-Yg#^P;QK8FTZAe)*|CKS6(I>8a2aoN+XEkYf2jAF!Zi3! zjS($tF@bu(ypeC>`IZtF;jz`F6A-Y7ZUQBuZxp&q4zHb9cc*!1`T3p9xL9`nWhNVr z!2lf=fCA>;1E&E|yfmrHqB#XnUCu28b*4#eZ{lLL(42#`ui?BO&uZj|d_Fh!Bw8g$ zn@2uezsJz@^XM(T{!CEw+EyG*eaF`FuTN%C zOZg)khBpDobCl(3ud$bhr>EdmuQ^l^Cic|y2m>LM+gsZGYKUAeJE5YUX9}j^JDoojv<}Cm&t+agmp?JE0%d#fo}m_cYogpjn5&egilTvDFz-Df}1i zB4)bXfn$dqb!cCa13DdCgMNehaa&${n5Mw&bxeKfNmHq%e{T_H@WB!H3QgFK2gNpB zP<;xkez-y-Lr(0^P^G!YH~WLut`0=mPXbVN64iv6Nd`s=eUQ;?V((+QU0&B4SF3*{Pm$AVrq;v&)c>VLy_UCe45VEsI@ZWM2TaB# zRU6XaLx0^H=0)Z!$rIu`3*s{Z!W7pU@6aHvX*vUuzME+!B5H}k_gFD)3=f;nI zi1|B!@iO%p;L{!JSEI~vyUByf_{HY=;RuAK##-h!06XFwxYi?xl}oWStJ*P{OcVe~ z_v(y8!+BaLQB`(D(XrL0ReKMn$R)8mU2@$q$Pq; zbZq-$IkP4V(`m}e<)cwnZLrjiA-X0@VY~Gi5-PKX20#Eag!JOw1br%7Rr}`(v@d!u zCo@&wE1SwM=zt~$K!eJ**9GAv!}Cogn9(d0X~BwPkU4gaWh?WVRcE3N?C%_R_D)Vw z(YmJTJ_0~fhItqHPqoIFGQYE2!~?aSRa{vjcDWhy5>oT zGOMFTWfL`aLx-!QL(9r?~D6y9Uhq=af8z!rqg#p zXk%gE-;=@G>MUv7p@P#ni@zP*$YQwA0Dlc21`%pV;p!_F@xI(^eA5&SZ{rU?^Wj}! z6Y%C^eMYilc_~MAwqV`h=I0;WA)MqJ^$IvyJ-O0)*RuLYjTL1TWd|(NbhIZ;nOop( z`4bc=fsxaeI@zc!vvYFFetFRKSMjef2_#oIzzPIxZ4oB0sxKOzX4Wltz#G@LD2Qr5 zm9o~xF;EU*_!O`}IigC{sU%1^$$B@>Fa_H0*>*1Amc^7tnKxcPpr8zZTme`6(0@J| zXfBE;0)lcuv%tqq05V8P2B^)Nhq~qdR|1KCfe>(GeuFaNc)T~zvma>o)FZv;sVD@D zynx%jpd8m<{zI zz44BQcmN85TNhy2plu`Nt$b;sKELSBpW)my@*ZnL{lFaD|7-8c-;zw*wh@(1yH+~o zQd6mwOU~P(B4CS|mX=v+F44&NRvMbQpcpDmU!|BhndzGgrsa}~;RGs*v>~aLX|A9$ zxrCyC3y6ZiciVh3@BH@t1LJY%FM8{e94DY4JQ} zYS0fcOC|N!{@iq*a@H$Qe9ONriBWJrhLhC?o5K2)!=~i)0hGh-mMd~RkqdIGCB(fU zy5*IvHssJ&gxudt>g(3w2{)axskJ_#h96qTc~<{c!`n^f zg+SOfdm8=UI!4%}d%RkXd}yWU1H66h)eDTsQr!qkcZE^zbI#F$k(dn7l7z}@YSv1+ zIcEYw{HJjfg()x7R@zQ&o;LdJ2vi6Fkl?OHM-Ga!%w}co(6=I5LZ>n{9pr~6!z|S$ zq_VfE7##n|{H(t$wPI-D`~L#((@V(MZ>p6Eb8k%4{lIGT;hZ9cg%~HhcbDCd%0RbM zs?uZG1wSL{Z0f+NzDiO?w9~XT^dWptKJ@M~0(@5*az*ZgabU465JN9eFY7vD8Wdz_ zlAIonnlivB;uDXov3sIgoKx2>G6a;@?v0qg;r`RnZ{4wMw2%}(e*c8k`R7sNT@>H} zfUU~mHR~8!4rJTHVlT=v3wz2kx&95Nz?@Tj8)s5E}t{|AFA=d_Y zOTqb{ATx>U``k~NJ2hYk3r#Gn1}|1Xj}jq!9%;{k(?9!WZt1z#{OATvapC-}#$LWi zi2R>~v0v6A<|?Eg)Ye#VyRyr7RJ$N4vFEFfmb1jHF(yZN^rc!ULDen>KWu(D9Z5!P ze(qg(G2HmSqyi2B&W`vo@N=3l?+dXbWn-`1LrY1^_mSilpKLLxQp}@s?=Tqw6Do5Pui*IhPZtaT|GAE&MF$;(4s9Bt5f+vbITElRv3( ze&@3GgY%ltiz;PZXq||TeA+sP9bc(#*G<2ck&zF3W?0$Bxit`EwvZb7jke;810>h3 zb}}!oS_xUbJ^$_PWrSlJ-;v4qq!@|L9uM#ALcMu|+|fni+AqPpu+CtjBrs#Y1jKVU zEc6L$d!2l-MgMi5&7?{Dfxj)qn;mIZudn7I6V$88%05A!PtCQTGSxXKMGh;qXa|fE zJBUmhM!}@e#A?s%bajm+=Ka1WxHZWaj;k#XT{T#;bH9c5zA8txVHEz(EeE*PP9eD9 z<2|evdxmVLj_n@`lp>6@ zy_ZTczm54_lGjPwPaq$dF1HdIks&Mp;%bge$QZnnp${}#&Z3)z95ei@b9;c=kJpY- z$G#RZbgyTi3&d4=3%+gXOSp|g^~^%K1id>re4gTka;7m@WA}bFo`GUbT8-n19VVdO}IkuW(H_iil_S}@$xy(Q*fCcNaD60 zxqsWK5lESLWnKgy^ci@da#k9^aW5)oLzbFxlUVBA&UM~79PF7=rW@Ot`>9(Gju3N{A4%EK0dPuz{=J_LUv|Pe^*x3eq_ExMNjB3?{$+xH^_Y z;e5pH)*~Lo@y=;b=P$Iqp9KR|j(>D-kaI4WeI&&HPFRtbZBMiQ^PwE`pF$Z7#(@UF zP2~&InXDTNx3`4)H2mD8yHl{Jk(|C(VA2vwY}3IRqo*qy9HvN7a!$$hlZqjmb6tZy zp1fLd^be5LmcI`_d3@@A`jLDS!b0qXVvP%y>+DfL86Ie=*TZ)PL??Lk^F};4=dwv; zPRBV>*)f&NE0vtjYHw@vs9l(Dk*g-}ARSciwv!f)E361d_9y<;9b7)PBw$3dh`AZi zAY4)BVh3t>;gR=s)nZW3PT_3bOLDK)eTZT^*m%P!HdC!FvK=Z=_iA>Bg!`SsC|P3u zz+oMr^PUcTebccFK>bqp475+?5RUC{Y7klp^p=Q;ZM+c8Zq6wBtH*5c=QHlp7wZS%6AszeebN>>_2^H7uuK@g%1{vF}DT>U{h`}c+u5ubXcFMH)fZ6-l z!y=qVN>jqgj)3T!mALcM;1!8}PDcMCU6<9?l#euNff${zE=b0d%;TcPFfw`y>zjLg#_WgnwatH|t}Y&WrR32m5W_AWNa`OqIc{ zW{_mX(Ck1psRCgMhJ*hXhcAG1ocb_kuY)%9rlYzq8h$K;X}=5m+8CYpJ4Yw6zLi%S zpu}dkAc_hVv>NfWy9eLsQ-6OzoBl{WAkRi|U;anmJ5dFwz(C9~-A(!Vfw z(E!S5ua;@}(q5GrIc6|PAOSPg{il$s$UBI}tk5xuP-VedGyZd}xqXvWvU_`{;Cf0> z5fN79T(#iq-q$RLb(of0ZA0lfepj^!a2-6 zv{v^7r2J*xmj&XVgZ>Wd=RqwGGe1`-Svll~bz(-y7*N1ooU5J*aY@&5ea5ss6n(a? z`N9l?w~=^1g2wLDVRD5ovqLc^Z#YRDFR+QYV4emH*fzOpzer3>Pudh??f``be>dD3 z)xB}1O6bZpnt=j(m92Fxq0dz89n>B05xx10QDL-YDz&e>h_u@9+RG)Pv4{2IYNiMy z8auH}j+fW*;q%Ymtbq+KI_r4gxGUeYJ>hq~vbe!N3%NntH+Dyh7I70!cu(qE_`Vp; z07NvH4Q2s#9;mKj;>umoviK|H+#CbgGq`D+QxI*$r6&D`yf%-M^{H;6gi4*j3?c9c z8$}NK?0I4%b?c`p2;SvL3*xY`0fe_KIZqPm`M%{DCrPUt{bS|zlhbHBNlUe7zcK}E z$L2zIl+z#Z!thJW!}{G&JAC@Pg`H(}GLM_m;uV}C9Yt(vF+F0Dy7{`k zY&v=ZZf?8^qSD>~2iP#{qQK632aMplZye6Q3X>dctS@JHSz2)zJaqXvFEZlr>9$oY z^&9^4pN`1EJcEw_wi@P{zJqQX470?WZTB*5Y7F!3#xJO^z|Gw@)bFoY5#daTP5OgI zcbKI$Ok(|9g_%#If*$3ga=U0_n%|#}eWwyeW~(19Te+!xF*(rd=LU(nM15;<7Z&oA zrqIw#r7}&_qgCdvS7+!|3?8w7JNRtHQ$~8Yyw(xC+n=- z7SQBo3+)tbg2NJn^=lukNOCkiEsgt~4tCrZ{aSnrHRMk@_?1^whFrEn3mT1NSC9B&c-(JrWu@FUhSNf+(>-_%kX#@LYnzq`^M#XX}(*!_LZCY za24(5Y$WH^=;GY^#0c{Y4{_!GPvm_bd#&6ypUpfwu%|+=UEe^Q+oe$7cXnyF@O67L3%SKO#rdayD^4^vH2hG{w%vp|_*jKf4 z=jb?40UP4S+Mi~(Uz(^cvgVB+r+Rt|;wnFRYcz(i=&Q14Ok=V-tTPw4%v&;ZrxI#w z6&rvLjj#yzBr5~N*7o09CkIE=>EWwo`ceL*@Y=504RB*xY#SY{)p3Gvn9zBL_FCN0 zl^axu8p~su8HpiDNi{%5ojAv1{0?t7*mflF9&Y_x4#)X(jyLl~c+s6*I1G7{zBI;tH*_ z94)o##4$cU4ohj~e#C^E><)3E`d;ftdwTQZpDmp)9)n5^+h%BE?)8LI2A`L!zjTBL zPYE&+#0&jDFc&4Tg}VC}E@4ZGyWbiK2dvn6Mpu!cQT_^6!RG!7)fE>V>?PNFm?vc5 z>A8gcW=5Xm2#LEW_;XgMQ$=Y-#lc|zs2}}2ny_4Kb%D@Vrtu6rOmUe!ph7;;L`XHi zXcDHc;OYbIk44?|A9-=Ml{Xap)^{jb5$Kl?v`CIT`bDXV*x{h+UARtzOd}#US>a%X zOdU`5^_P@lkQxB*B<&RQB?FgJOH2-~rMnXf_{5%~s&OlUM^i30FeOM{`XOXs)3_BU zEAyNr%bz8RJ=Cvw8y=)3p z`K|i!j$l~LqQ)kabHK}7WeyB$x*({t#cQWf98qh&X{R*Y--9)~g)?XCL>&z;v9#hY zTFY?DV&1fPE&*z}6Ki`Y5#(-eVYB;OzZjPSDnN%ArA8D>wODpQT4Jt}ah556JE+G_! z_P0uQ!qDhR94VdpAqajIOl4~>oTaQ8H5yXaTZUOb%cRAkWYV?KSNlTqgSM=Wgf)JP zz=?Q5f5zPEVO!NbOCbqEwP^Ff_O_`gdm67#U{Mp^_bKcq2IoO%zcJb(M5z`cjv1Ck z+!awNRhwjj6CQqu+xC#{UWo^3+h?6ymzq3r?3JV}<|u_9x=MWAm`1AqAnOsJ*@)^4 zr|`FkZlg{Cd!#Chmhn=_ZQe;~-DTUOv>)Tbmh0{z_42vWa|vNUO% z_5KA1xNHBgw0zjUH|s5xg$b4k z@Koa#-AFizrr6h2#$k*41tm7_jp$yL4X*DZcklq!u+>9E0WnhcOFPn7Vh^ao@~tno z@RwY)*+8&|Hpdq)`a=L*Teuw;_B@u;o!a!YaOO@bs-?*gqpm?nRkXl~mKFfF z+OVzE%RlC`M5-+KM_GXZ@9b;=2C(sq+R&Ko_RzZ%5P~kDieK3yzV4BN*{$E%KY;4k z)s?*vacHYN~u+?SoI`e@S2!9Co!cdvz;@N@{yj`0-9^8osR(V7PR-O&gM)x3owqs5oJpIwc zgY`#VzjI$V>YYDrIr8D;0JK<10@ycefw z;;oV(!gUR*xBg%xTl-#d>u(5}#jFrLKo}q0b{IuuZhuO7n++ zo@9)d#`(AT$mbW5g;c;&z>1_2Nk%;L?TIhfeK%PYp>5N<5wdihxw4-qvVsN6t@bol zDFgi~t`B&ZU3ek!#fXVE5Ao$7AwI+@amT_m2SclwQE{cLcv3kwhokq+!S%>Fe_*(Z z75)vhq@YqZqa~Hf$0S?T@nr_%mV%*aT${~4)6|(P@Bq_Q!VC4tZa`7?ra`4?oV+wSr2`TVSUmKS_>V@3%0*S#!+L=3f@oF=4k9U9xv0p1;Fx&}V;X2J~h zcz^}G3|;s8JyEFR*LB*fPUm+?f+ofnBQ5uK%NrwA+RV_~h<6-mw_wU?NGRI!zNTh% z&>ty6x8&gW75gdW)?p->&%?{*brS|k@b|(>&<^nyO55Pi_q*eK)=J*Uunw2cw--p%E!VXuDa? ztZ$HPKJ6$Sh7!UrpxVBLFSnpZOw$(ftvg!Nk1LVfL+FL(u zh1Abu(oCSmgqQ2IrE;Zz2f2DAD%T4XO6tU&)2IB}vV3{^xpz1MYFEPy_09RP2QvmA zIqw<(UaCnCs!mFX$+3sjnV*(O5)y`jW!*wzF-l^K`Bxgap+0Ej z@c^nf{Ic`6I5#9bcE7fwiiP8JZ9dr3FsD~SBiW_`8{UgFt*{$@qj#E)90JYra>Zs3 z$sCTuzOye2GdTO;4@;wgJK@!ij-|c--insluCR}{#q=D6Xz#nL6;`rkc*UzLTR%Y{ zN2YK;Zcz4YY=+|(0_?E=#~3U@I1fIyRiBF zIeWj=id+b|L;kSMs>NMfeB^(={IdrC;NYJy_$L+olL`OdOqgH0OpSa?FTRhwb<|%A Pe7HEdAEg|=c=LY&YVNkY literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..13b35eba55c6dabc3aac36f33d859266c18fa0d0 GIT binary patch literal 5680 zcmaiYXH?Tqu=Xz`p-L#B_gI#0we$cm_HcmYFP$?wjD#BaCN4mzC5#`>w9y6=ThxrYZc0WPXprg zYjB`UsV}0=eUtY$(P6YW}npdd;%9pi?zS3k-nqCob zSX_AQEf|=wYT3r?f!*Yt)ar^;l3Sro{z(7deUBPd2~(SzZ-s@0r&~Km2S?8r##9-< z)2UOSVaHqq6}%sA9Ww;V2LG=PnNAh6mA2iWOuV7T_lRDR z&N8-eN=U)-T|;wo^Wv=34wtV0g}sAAe}`Ph@~!|<;z7*K8(qkX0}o=!(+N*UWrkEja*$_H6mhK1u{P!AC39} z|3+Z(mAOq#XRYS)TLoHv<)d%$$I@+x+2)V{@o~~J-!YUI-Q9%!Ldi4Op&Lw&B>jj* zwAgC#Y>gbIqv!d|J5f!$dbCXoq(l3GR(S>(rtZ~Z*agXMMKN!@mWT_vmCbSd3dUUm z4M&+gz?@^#RRGal%G3dDvj7C5QTb@9+!MG+>0dcjtZEB45c+qx*c?)d<%htn1o!#1 zpIGonh>P1LHu3s)fGFF-qS}AXjW|M*2Xjkh7(~r(lN=o#mBD9?jt74=Rz85I4Nfx_ z7Z)q?!};>IUjMNM6ee2Thq7))a>My?iWFxQ&}WvsFP5LP+iGz+QiYek+K1`bZiTV- zHHYng?ct@Uw5!gquJ(tEv1wTrRR7cemI>aSzLI^$PxW`wL_zt@RSfZ1M3c2sbebM* ze0=;sy^!90gL~YKISz*x;*^~hcCoO&CRD)zjT(A2b_uRue=QXFe5|!cf0z1m!iwv5GUnLw9Dr*Ux z)3Lc!J@Ei;&&yxGpf2kn@2wJ2?t6~obUg;?tBiD#uo$SkFIasu+^~h33W~`r82rSa ztyE;ehFjC2hjpJ-e__EH&z?!~>UBb=&%DS>NT)1O3Isn-!SElBV2!~m6v0$vx^a<@ISutdTk1@?;i z<8w#b-%|a#?e5(n@7>M|v<<0Kpg?BiHYMRe!3Z{wYc2hN{2`6(;q`9BtXIhVq6t~KMH~J0~XtUuT06hL8c1BYZWhN zk4F2I;|za*R{ToHH2L?MfRAm5(i1Ijw;f+0&J}pZ=A0;A4M`|10ZskA!a4VibFKn^ zdVH4OlsFV{R}vFlD~aA4xxSCTTMW@Gws4bFWI@xume%smAnuJ0b91QIF?ZV!%VSRJ zO7FmG!swKO{xuH{DYZ^##gGrXsUwYfD0dxXX3>QmD&`mSi;k)YvEQX?UyfIjQeIm! z0ME3gmQ`qRZ;{qYOWt}$-mW*>D~SPZKOgP)T-Sg%d;cw^#$>3A9I(%#vsTRQe%moT zU`geRJ16l>FV^HKX1GG7fR9AT((jaVb~E|0(c-WYQscVl(z?W!rJp`etF$dBXP|EG z=WXbcZ8mI)WBN>3<@%4eD597FD5nlZajwh8(c$lum>yP)F}=(D5g1-WVZRc)(!E3} z-6jy(x$OZOwE=~{EQS(Tp`yV2&t;KBpG*XWX!yG+>tc4aoxbXi7u@O*8WWFOxUjcq z^uV_|*818$+@_{|d~VOP{NcNi+FpJ9)aA2So<7sB%j`$Prje&auIiTBb{oD7q~3g0 z>QNIwcz(V-y{Ona?L&=JaV5`o71nIsWUMA~HOdCs10H+Irew#Kr(2cn>orG2J!jvP zqcVX0OiF}c<)+5&p}a>_Uuv)L_j}nqnJ5a?RPBNi8k$R~zpZ33AA4=xJ@Z($s3pG9 zkURJY5ZI=cZGRt_;`hs$kE@B0FrRx(6K{`i1^*TY;Vn?|IAv9|NrN*KnJqO|8$e1& zb?OgMV&q5|w7PNlHLHF) zB+AK#?EtCgCvwvZ6*u|TDhJcCO+%I^@Td8CR}+nz;OZ*4Dn?mSi97m*CXXc=};!P`B?}X`F-B5v-%ACa8fo0W++j&ztmqK z;&A)cT4ob9&MxpQU41agyMU8jFq~RzXOAsy>}hBQdFVL%aTn~M>5t9go2j$i9=(rZ zADmVj;Qntcr3NIPPTggpUxL_z#5~C!Gk2Rk^3jSiDqsbpOXf^f&|h^jT4|l2ehPat zb$<*B+x^qO8Po2+DAmrQ$Zqc`1%?gp*mDk>ERf6I|42^tjR6>}4`F_Mo^N(~Spjcg z_uY$}zui*PuDJjrpP0Pd+x^5ds3TG#f?57dFL{auS_W8|G*o}gcnsKYjS6*t8VI<) zcjqTzW(Hk*t-Qhq`Xe+x%}sxXRerScbPGv8hlJ;CnU-!Nl=# zR=iTFf9`EItr9iAlAGi}i&~nJ-&+)Y| zMZigh{LXe)uR+4D_Yb+1?I93mHQ5{pId2Fq%DBr7`?ipi;CT!Q&|EO3gH~7g?8>~l zT@%*5BbetH)~%TrAF1!-!=)`FIS{^EVA4WlXYtEy^|@y@yr!C~gX+cp2;|O4x1_Ol z4fPOE^nj(}KPQasY#U{m)}TZt1C5O}vz`A|1J!-D)bR%^+=J-yJsQXDzFiqb+PT0! zIaDWWU(AfOKlSBMS};3xBN*1F2j1-_=%o($ETm8@oR_NvtMDVIv_k zlnNBiHU&h8425{MCa=`vb2YP5KM7**!{1O>5Khzu+5OVGY;V=Vl+24fOE;tMfujoF z0M``}MNnTg3f%Uy6hZi$#g%PUA_-W>uVCYpE*1j>U8cYP6m(>KAVCmbsDf39Lqv0^ zt}V6FWjOU@AbruB7MH2XqtnwiXS2scgjVMH&aF~AIduh#^aT1>*V>-st8%=Kk*{bL zzbQcK(l2~)*A8gvfX=RPsNnjfkRZ@3DZ*ff5rmx{@iYJV+a@&++}ZW+za2fU>&(4y`6wgMpQGG5Ah(9oGcJ^P(H< zvYn5JE$2B`Z7F6ihy>_49!6}(-)oZ(zryIXt=*a$bpIw^k?>RJ2 zQYr>-D#T`2ZWDU$pM89Cl+C<;J!EzHwn(NNnWpYFqDDZ_*FZ{9KQRcSrl5T>dj+eA zi|okW;6)6LR5zebZJtZ%6Gx8^=2d9>_670!8Qm$wd+?zc4RAfV!ZZ$jV0qrv(D`db zm_T*KGCh3CJGb(*X6nXzh!h9@BZ-NO8py|wG8Qv^N*g?kouH4%QkPU~Vizh-D3<@% zGomx%q42B7B}?MVdv1DFb!axQ73AUxqr!yTyFlp%Z1IAgG49usqaEbI_RnbweR;Xs zpJq7GKL_iqi8Md?f>cR?^0CA+Uk(#mTlGdZbuC*$PrdB$+EGiW**=$A3X&^lM^K2s zzwc3LtEs5|ho z2>U(-GL`}eNgL-nv3h7E<*<>C%O^=mmmX0`jQb6$mP7jUKaY4je&dCG{x$`0=_s$+ zSpgn!8f~ya&U@c%{HyrmiW2&Wzc#Sw@+14sCpTWReYpF9EQ|7vF*g|sqG3hx67g}9 zwUj5QP2Q-(KxovRtL|-62_QsHLD4Mu&qS|iDp%!rs(~ah8FcrGb?Uv^Qub5ZT_kn%I^U2rxo1DDpmN@8uejxik`DK2~IDi1d?%~pR7i#KTS zA78XRx<(RYO0_uKnw~vBKi9zX8VnjZEi?vD?YAw}y+)wIjIVg&5(=%rjx3xQ_vGCy z*&$A+bT#9%ZjI;0w(k$|*x{I1c!ECMus|TEA#QE%#&LxfGvijl7Ih!B2 z6((F_gwkV;+oSKrtr&pX&fKo3s3`TG@ye+k3Ov)<#J|p8?vKh@<$YE@YIU1~@7{f+ zydTna#zv?)6&s=1gqH<-piG>E6XW8ZI7&b@-+Yk0Oan_CW!~Q2R{QvMm8_W1IV8<+ zQTyy=(Wf*qcQubRK)$B;QF}Y>V6d_NM#=-ydM?%EPo$Q+jkf}*UrzR?Nsf?~pzIj$ z<$wN;7c!WDZ(G_7N@YgZ``l;_eAd3+;omNjlpfn;0(B7L)^;;1SsI6Le+c^ULe;O@ zl+Z@OOAr4$a;=I~R0w4jO`*PKBp?3K+uJ+Tu8^%i<_~bU!p%so z^sjol^slR`W@jiqn!M~eClIIl+`A5%lGT{z^mRbpv}~AyO%R*jmG_Wrng{B9TwIuS z0!@fsM~!57K1l0%{yy(#no}roy#r!?0wm~HT!vLDfEBs9x#`9yCKgufm0MjVRfZ=f z4*ZRc2Lgr(P+j2zQE_JzYmP0*;trl7{*N341Cq}%^M^VC3gKG-hY zmPT>ECyrhIoFhnMB^qpdbiuI}pk{qPbK^}0?Rf7^{98+95zNq6!RuV_zAe&nDk0;f zez~oXlE5%ve^TmBEt*x_X#fs(-En$jXr-R4sb$b~`nS=iOy|OVrph(U&cVS!IhmZ~ zKIRA9X%Wp1J=vTvHZ~SDe_JXOe9*fa zgEPf;gD^|qE=dl>Qkx3(80#SE7oxXQ(n4qQ#by{uppSKoDbaq`U+fRqk0BwI>IXV3 zD#K%ASkzd7u>@|pA=)Z>rQr@dLH}*r7r0ng zxa^eME+l*s7{5TNu!+bD{Pp@2)v%g6^>yj{XP&mShhg9GszNu4ITW=XCIUp2Xro&1 zg_D=J3r)6hp$8+94?D$Yn2@Kp-3LDsci)<-H!wCeQt$e9Jk)K86hvV^*Nj-Ea*o;G zsuhRw$H{$o>8qByz1V!(yV{p_0X?Kmy%g#1oSmlHsw;FQ%j9S#}ha zm0Nx09@jmOtP8Q+onN^BAgd8QI^(y!n;-APUpo5WVdmp8!`yKTlF>cqn>ag`4;o>i zl!M0G-(S*fm6VjYy}J}0nX7nJ$h`|b&KuW4d&W5IhbR;-)*9Y0(Jj|@j`$xoPQ=Cl literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3f5fa40fb3d1e0710331a48de5d256da3f275d GIT binary patch literal 520 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/patapata_example_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1632cfddf3d9dade342351e627a0a75609fb46 GIT binary patch literal 2218 zcmV;b2vzrqP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE6iGxuRCodHTWf3-RTMruyW6Fu zQYeUM04eX6D5c0FCjKKPrco1(K`<0SL=crI{PC3-^hZU0kQie$gh-5!7z6SH6Q0J% zqot*`H1q{R5fHFYS}dje@;kG=v$L0(yY0?wY2%*c?A&{2?!D*x?m71{of2gv!$5|C z3>qG_BW}7K_yUcT3A5C6QD<+{aq?x;MAUyAiJn#Jv8_zZtQ{P zTRzbL3U9!qVuZzS$xKU10KiW~Bgdcv1-!uAhQxf3a7q+dU6lj?yoO4Lq4TUN4}h{N z*fIM=SS8|C2$(T>w$`t@3Tka!(r!7W`x z-isCVgQD^mG-MJ;XtJuK3V{Vy72GQ83KRWsHU?e*wrhKk=ApIYeDqLi;JI1e zuvv}5^Dc=k7F7?nm3nIw$NVmU-+R>> zyqOR$-2SDpJ}Pt;^RkJytDVXNTsu|mI1`~G7yw`EJR?VkGfNdqK9^^8P`JdtTV&tX4CNcV4 z&N06nZa??Fw1AgQOUSE2AmPE@WO(Fvo`%m`cDgiv(fAeRA%3AGXUbsGw{7Q`cY;1BI#ac3iN$$Hw z0LT0;xc%=q)me?Y*$xI@GRAw?+}>=9D+KTk??-HJ4=A>`V&vKFS75@MKdSF1JTq{S zc1!^8?YA|t+uKigaq!sT;Z!&0F2=k7F0PIU;F$leJLaw2UI6FL^w}OG&!;+b%ya1c z1n+6-inU<0VM-Y_s5iTElq)ThyF?StVcebpGI znw#+zLx2@ah{$_2jn+@}(zJZ{+}_N9BM;z)0yr|gF-4=Iyu@hI*Lk=-A8f#bAzc9f z`Kd6K--x@t04swJVC3JK1cHY-Hq+=|PN-VO;?^_C#;coU6TDP7Bt`;{JTG;!+jj(` zw5cLQ-(Cz-Tlb`A^w7|R56Ce;Wmr0)$KWOUZ6ai0PhzPeHwdl0H(etP zUV`va_i0s-4#DkNM8lUlqI7>YQLf)(lz9Q3Uw`)nc(z3{m5ZE77Ul$V%m)E}3&8L0 z-XaU|eB~Is08eORPk;=<>!1w)Kf}FOVS2l&9~A+@R#koFJ$Czd%Y(ENTV&A~U(IPI z;UY+gf+&6ioZ=roly<0Yst8ck>(M=S?B-ys3mLdM&)ex!hbt+ol|T6CTS+Sc0jv(& z7ijdvFwBq;0a{%3GGwkDKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U z@~$z0V`OVtIbEx5pa|Tct|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-M2 z@O~lQ0OiKQp}o9I;?uxCgYVV?FH|?Riri*U$Zi_`V2eiA>l zdSm6;SEm6#T+SpcE8Ro_f2AwxzI z44hfe^WE3!h@W3RDyA_H440cpmYkv*)6m1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_example_app/macos/Runner/Configs/AppInfo.xcconfig b/packages/patapata_example_app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..818ce49 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = patapata_example_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.patapataExampleApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2023 com.example. All rights reserved. diff --git a/packages/patapata_example_app/macos/Runner/Configs/Debug.xcconfig b/packages/patapata_example_app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/patapata_example_app/macos/Runner/Configs/Release.xcconfig b/packages/patapata_example_app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/patapata_example_app/macos/Runner/Configs/Warnings.xcconfig b/packages/patapata_example_app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/patapata_example_app/macos/Runner/DebugProfile.entitlements b/packages/patapata_example_app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/packages/patapata_example_app/macos/Runner/Info.plist b/packages/patapata_example_app/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/patapata_example_app/macos/Runner/MainFlutterWindow.swift b/packages/patapata_example_app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..ca5847a --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,20 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/patapata_example_app/macos/Runner/Release.entitlements b/packages/patapata_example_app/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/packages/patapata_example_app/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/packages/patapata_example_app/macos/RunnerTests/RunnerTests.swift b/packages/patapata_example_app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..06224b4 --- /dev/null +++ b/packages/patapata_example_app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,17 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import FlutterMacOS +import Cocoa +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/patapata_example_app/pubspec.yaml b/packages/patapata_example_app/pubspec.yaml new file mode 100644 index 0000000..3518be2 --- /dev/null +++ b/packages/patapata_example_app/pubspec.yaml @@ -0,0 +1,93 @@ +name: patapata_example_app +description: This package is a sample application that demonstrates how to use the features of patapata_core. +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_example_app + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: '>=3.1.0 <4.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + provider: ^6.0.5 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + uses-material-design: true + + assets: + - l10n/ + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/patapata_example_app/web/favicon.png b/packages/patapata_example_app/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/web/icons/Icon-192.png b/packages/patapata_example_app/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/web/icons/Icon-512.png b/packages/patapata_example_app/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/web/icons/Icon-maskable-192.png b/packages/patapata_example_app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/web/icons/Icon-maskable-512.png b/packages/patapata_example_app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/web/index.html b/packages/patapata_example_app/web/index.html new file mode 100644 index 0000000..61df46e --- /dev/null +++ b/packages/patapata_example_app/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + patapata_example_app + + + + + + + + + + diff --git a/packages/patapata_example_app/web/manifest.json b/packages/patapata_example_app/web/manifest.json new file mode 100644 index 0000000..2926f6d --- /dev/null +++ b/packages/patapata_example_app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "patapata_example_app", + "short_name": "patapata_example_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/patapata_example_app/windows/.gitignore b/packages/patapata_example_app/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/packages/patapata_example_app/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/patapata_example_app/windows/CMakeLists.txt b/packages/patapata_example_app/windows/CMakeLists.txt new file mode 100644 index 0000000..f69ed51 --- /dev/null +++ b/packages/patapata_example_app/windows/CMakeLists.txt @@ -0,0 +1,102 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(patapata_example_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "patapata_example_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/patapata_example_app/windows/flutter/CMakeLists.txt b/packages/patapata_example_app/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..930d207 --- /dev/null +++ b/packages/patapata_example_app/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/patapata_example_app/windows/flutter/generated_plugin_registrant.cc b/packages/patapata_example_app/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8777c93 --- /dev/null +++ b/packages/patapata_example_app/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); +} diff --git a/packages/patapata_example_app/windows/flutter/generated_plugin_registrant.h b/packages/patapata_example_app/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/packages/patapata_example_app/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/patapata_example_app/windows/flutter/generated_plugins.cmake b/packages/patapata_example_app/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..cc1361d --- /dev/null +++ b/packages/patapata_example_app/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/patapata_example_app/windows/runner/CMakeLists.txt b/packages/patapata_example_app/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/packages/patapata_example_app/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/patapata_example_app/windows/runner/Runner.rc b/packages/patapata_example_app/windows/runner/Runner.rc new file mode 100644 index 0000000..fab211a --- /dev/null +++ b/packages/patapata_example_app/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "patapata_example_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "patapata_example_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2023 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "patapata_example_app.exe" "\0" + VALUE "ProductName", "patapata_example_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/patapata_example_app/windows/runner/flutter_window.cpp b/packages/patapata_example_app/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/packages/patapata_example_app/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/patapata_example_app/windows/runner/flutter_window.h b/packages/patapata_example_app/windows/runner/flutter_window.h new file mode 100644 index 0000000..5f56958 --- /dev/null +++ b/packages/patapata_example_app/windows/runner/flutter_window.h @@ -0,0 +1,40 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/patapata_example_app/windows/runner/main.cpp b/packages/patapata_example_app/windows/runner/main.cpp new file mode 100644 index 0000000..35e0baf --- /dev/null +++ b/packages/patapata_example_app/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"patapata_example_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/patapata_example_app/windows/runner/resource.h b/packages/patapata_example_app/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/packages/patapata_example_app/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/patapata_example_app/windows/runner/resources/app_icon.ico b/packages/patapata_example_app/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/packages/patapata_example_app/windows/runner/runner.exe.manifest b/packages/patapata_example_app/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..a42ea76 --- /dev/null +++ b/packages/patapata_example_app/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/packages/patapata_example_app/windows/runner/utils.cpp b/packages/patapata_example_app/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0c238 --- /dev/null +++ b/packages/patapata_example_app/windows/runner/utils.cpp @@ -0,0 +1,70 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/patapata_example_app/windows/runner/utils.h b/packages/patapata_example_app/windows/runner/utils.h new file mode 100644 index 0000000..7ea5112 --- /dev/null +++ b/packages/patapata_example_app/windows/runner/utils.h @@ -0,0 +1,26 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/patapata_example_app/windows/runner/win32_window.cpp b/packages/patapata_example_app/windows/runner/win32_window.cpp new file mode 100644 index 0000000..14de6bd --- /dev/null +++ b/packages/patapata_example_app/windows/runner/win32_window.cpp @@ -0,0 +1,293 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/patapata_example_app/windows/runner/win32_window.h b/packages/patapata_example_app/windows/runner/win32_window.h new file mode 100644 index 0000000..aea7be9 --- /dev/null +++ b/packages/patapata_example_app/windows/runner/win32_window.h @@ -0,0 +1,109 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/patapata_firebase_analytics/.gitignore b/packages/patapata_firebase_analytics/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_firebase_analytics/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_firebase_analytics/.metadata b/packages/patapata_firebase_analytics/.metadata new file mode 100644 index 0000000..4311ca2 --- /dev/null +++ b/packages/patapata_firebase_analytics/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: package diff --git a/packages/patapata_firebase_analytics/CHANGELOG.md b/packages/patapata_firebase_analytics/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_firebase_analytics/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_firebase_analytics/LICENSE b/packages/patapata_firebase_analytics/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_firebase_analytics/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_firebase_analytics/README.md b/packages/patapata_firebase_analytics/README.md new file mode 100644 index 0000000..0b1b49e --- /dev/null +++ b/packages/patapata_firebase_analytics/README.md @@ -0,0 +1,63 @@ + + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Firebase Analytics](https://firebase.google.com/docs/analytics/) to your Patapata app. +It will automatically log analytics events to Firebase Analytics from Patapata's Analytics system. + +This plugin requires the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) plugin to be installed and activated. + +--- + +Due to a bug in the FlutterFire CLI, the stable version of 0.2.7 (at the time of writing this README) does not write out the required paramaters to run firebase_analytics correctly. + +A temporary workaround is to use the dev version of the cli and run `flutterfire configure` again. +https://github.com/invertase/flutterfire_cli/issues/210#issuecomment-1770505141 + +The above still might not be enough in some cases. +If you are still having issues, try the following: +- Add `classpath 'com.google.gms:google-services:4.3.14'` to `android/build.gradle`. Make sure the version is exactly that. +- Add `apply plugin: 'com.google.gms.google-services'` to `android/app/build.gradle` at the bottom of the file. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_firebase_analytics +``` + +2. Import the package + +```dart +import 'package:patapata_firebase_analytics/patapata_firebase_analytics.dart'; +``` + +4. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + FirebaseCorePlugin(), + FirebaseAnalyticsPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_firebase_analytics/LICENSE) diff --git a/packages/patapata_firebase_analytics/analysis_options.yaml b/packages/patapata_firebase_analytics/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_firebase_analytics/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_firebase_analytics/dartdoc_options.yaml b/packages/patapata_firebase_analytics/dartdoc_options.yaml new file mode 100644 index 0000000..fd7c0de --- /dev/null +++ b/packages/patapata_firebase_analytics/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_firebase_analytics \ No newline at end of file diff --git a/packages/patapata_firebase_analytics/lib/patapata_firebase_analytics.dart b/packages/patapata_firebase_analytics/lib/patapata_firebase_analytics.dart new file mode 100644 index 0000000..e9b3c6c --- /dev/null +++ b/packages/patapata_firebase_analytics/lib/patapata_firebase_analytics.dart @@ -0,0 +1,91 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_firebase_analytics; + +import 'dart:async'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_firebase_core/patapata_firebase_core.dart'; + +/// A plugin that provides functionality for Firebase Analytics. +/// This plugin requires adding the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) package to your application. +class FirebaseAnalyticsPlugin extends Plugin { + /// A reference to the [FirebaseAnalytics] instance. + late final FirebaseAnalytics backend; + late final StreamSubscription _eventsSubscription; + + @override + List get dependencies => [FirebaseCorePlugin]; + + /// Initializes the [FirebaseAnalyticsPlugin]. + @override + FutureOr init(App app) async { + await super.init(app); + + backend = FirebaseAnalytics.instance; + _eventsSubscription = + app.analytics.eventsFor().listen(_onEvent); + app.user.addSynchronousChangeListener(_onUserChanged); + + return true; + } + + @override + FutureOr dispose() async { + await super.dispose(); + + app.user.removeSynchronousChangeListener(_onUserChanged); + _eventsSubscription.cancel(); + backend.setAnalyticsCollectionEnabled(false); + } + + @override + List get navigatorObservers => + [FirebaseAnalyticsObserver(analytics: backend)]; + + void _onEvent(AnalyticsEvent event) { + // Firebase only allows int, double, and String as parameter types. + // And no null. However Analytics needs the same keys, so we pass '' in that case. + // For any other types, try to turn it in to JSON. If that fails, return the toString() of it. + final tFlatData = event.flatData; + final tParameters = tFlatData != null + ? { + for (var i in tFlatData.entries) + i.key: Analytics.defaultMakeLoggableToNative(i.value), + } + : null; + + backend.logEvent( + name: event.name, + parameters: { + if (event.navigationInteractionContextData != null) + for (var i in event.navigationInteractionContextData!.entries) + 'nic_${i.key}': Analytics.defaultMakeLoggableToNative(i.value), + }..addAll(tParameters ?? {}), + ); + } + + String? _lastId; + + FutureOr _onUserChanged(User user, UserChangeData changes) async { + final tId = changes.getIdFor(); + final tProperties = changes.getPropertiesFor(); + + if (tId != _lastId) { + _lastId = tId; + await backend.setUserId( + id: tId, + ); + } + + await Future.wait([ + for (var i in tProperties.entries) + backend.setUserProperty(name: i.key, value: i.value?.toString()), + ]); + } +} diff --git a/packages/patapata_firebase_analytics/pubspec.yaml b/packages/patapata_firebase_analytics/pubspec.yaml new file mode 100644 index 0000000..c755067 --- /dev/null +++ b/packages/patapata_firebase_analytics/pubspec.yaml @@ -0,0 +1,26 @@ +name: patapata_firebase_analytics +description: This package is a plugin for Patapata that adds support for Firebase Analytics to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_firebase_analytics + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + patapata_firebase_core: ^1.0.0 + + firebase_analytics: ^10.4.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: diff --git a/packages/patapata_firebase_analytics/test/patapata_firebase_analytics_test.dart b/packages/patapata_firebase_analytics/test/patapata_firebase_analytics_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_firebase_analytics/test/patapata_firebase_analytics_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_firebase_auth/.gitignore b/packages/patapata_firebase_auth/.gitignore new file mode 100644 index 0000000..36cf1be --- /dev/null +++ b/packages/patapata_firebase_auth/.gitignore @@ -0,0 +1,79 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/doc/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +# File generated when the golden test fails. +/test/failures diff --git a/packages/patapata_firebase_auth/.metadata b/packages/patapata_firebase_auth/.metadata new file mode 100644 index 0000000..a8338a2 --- /dev/null +++ b/packages/patapata_firebase_auth/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + channel: stable + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + - platform: android + create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + - platform: ios + create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + - platform: linux + create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + - platform: macos + create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + - platform: web + create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + - platform: windows + create_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + base_revision: 2ad6cd72c040113b47ee9055e722606a490ef0da + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/patapata_firebase_auth/CHANGELOG.md b/packages/patapata_firebase_auth/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_firebase_auth/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_firebase_auth/LICENSE b/packages/patapata_firebase_auth/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_firebase_auth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_firebase_auth/README.md b/packages/patapata_firebase_auth/README.md new file mode 100644 index 0000000..462c6aa --- /dev/null +++ b/packages/patapata_firebase_auth/README.md @@ -0,0 +1,63 @@ +
    +

    Patapata - Firebase Authentication

    +

    + Add support for Firebase Authentication to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Firebase Authentication](https://firebase.google.com/docs/auth/) to your Patapata app. +It integrates with Firebase Authentication and supports user authentication functionality. + +This plugin requires the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) plugin to be installed and activated. + +--- + +Due to a bug in the FlutterFire CLI, the stable version of 0.2.7 (at the time of writing this README) does not write out the required paramaters to run firebase_auth correctly. + +A temporary workaround is to use the dev version of the cli and run `flutterfire configure` again. +https://github.com/invertase/flutterfire_cli/issues/210#issuecomment-1770505141 + +The above still might not be enough in some cases. +If you are still having issues, try the following: +- Add `classpath 'com.google.gms:google-services:4.3.14'` to `android/build.gradle`. Make sure the version is exactly that. +- Add `apply plugin: 'com.google.gms.google-services'` to `android/app/build.gradle` at the bottom of the file. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_firebase_auth +``` + +2. Import the package + +```dart +import 'package:patapata_firebase_auth/patapata_firebase_auth.dart'; +``` + +4. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + FirebaseCorePlugin(), + FirebaseAuthPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_firebase_auth/LICENSE) diff --git a/packages/patapata_firebase_auth/analysis_options.yaml b/packages/patapata_firebase_auth/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/packages/patapata_firebase_auth/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/patapata_firebase_auth/lib/patapata_firebase_auth.dart b/packages/patapata_firebase_auth/lib/patapata_firebase_auth.dart new file mode 100644 index 0000000..d40d41b --- /dev/null +++ b/packages/patapata_firebase_auth/lib/patapata_firebase_auth.dart @@ -0,0 +1,70 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:firebase_auth/firebase_auth.dart' as firebase; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; + +/// A plugin that provides functionality for Firebase Authentication. +/// This plugin requires adding the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) package to your application. +class FirebaseAuthPlugin extends Plugin { + /// A reference to the [firebase.FirebaseAuth] instance. + late final firebase.FirebaseAuth instance; + + /// A StreamSubscription for [firebase.User?] type. + /// This can be used to listen to changes in the user's authentication status. + StreamSubscription? userSubscription; + + /// Initializes the [FirebaseAuthPlugin]. + @override + FutureOr init(App app) async { + await super.init(app); + + instance = firebase.FirebaseAuth.instance; + + enableUserChangesSubscription(); + + return true; + } + + /// Synchronize User.id in App and User.uid logged in Firebase. + /// + /// To enable this functionality, you need to call this function + /// after creating the [App] instance. + /// For example: + /// ```dart + /// void main() async { + /// final App tApp = App( + /// environment: ..., + /// plugins: [ + /// ..., + /// FirebaseAuthPlugin(), + /// ..., + /// ], + /// createAppWidget: ..., + /// ); + /// + /// tApp.run(); + /// } + /// ``` + void enableUserChangesSubscription() async { + userSubscription = instance.userChanges().listen( + (user) { + if (user != null && user.uid != app.user.id) { + app.user.changeId(user.uid); + } + }, + ); + } + + /// Dispose the [FirebaseAuthPlugin]. + @override + FutureOr dispose() async { + await super.dispose(); + + userSubscription?.cancel(); + userSubscription = null; + } +} diff --git a/packages/patapata_firebase_auth/pubspec.yaml b/packages/patapata_firebase_auth/pubspec.yaml new file mode 100644 index 0000000..8742624 --- /dev/null +++ b/packages/patapata_firebase_auth/pubspec.yaml @@ -0,0 +1,24 @@ +name: patapata_firebase_auth +description: This package is a plugin for Patapata that adds support for Firebase Authentication to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_firebase_auth + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + firebase_auth: ^4.2.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.1 + +flutter: diff --git a/packages/patapata_firebase_auth/test/patapata_firebase_auth_test.dart b/packages/patapata_firebase_auth/test/patapata_firebase_auth_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_firebase_auth/test/patapata_firebase_auth_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_firebase_core/.gitignore b/packages/patapata_firebase_core/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_firebase_core/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_firebase_core/.metadata b/packages/patapata_firebase_core/.metadata new file mode 100644 index 0000000..4311ca2 --- /dev/null +++ b/packages/patapata_firebase_core/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: package diff --git a/packages/patapata_firebase_core/CHANGELOG.md b/packages/patapata_firebase_core/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_firebase_core/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_firebase_core/LICENSE b/packages/patapata_firebase_core/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_firebase_core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_firebase_core/README.md b/packages/patapata_firebase_core/README.md new file mode 100644 index 0000000..268ea17 --- /dev/null +++ b/packages/patapata_firebase_core/README.md @@ -0,0 +1,68 @@ +
    +

    Patapata - Firebase Core

    +

    + Add support for Firebase to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Firebase](https://firebase.google.com/) to your Patapata app. + +This plugin itself just initializes Firebase. It does not do anything else itself. You will need to add other plugins to your app to use Firebase features. + +## Getting started + +1. Follow the instructions for your platform to set up Firebase at https://firebase.google.com/docs/flutter/setup, up until just after executing `flutterfire configure`. + +2. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_firebase_core +``` + +3. Import the package and the settings from your `firebase_options.dart` file + +```dart +import 'package:patapata_firebase_core/patapata_firebase_core.dart'; +import 'firebase_options.dart'; +``` + +4. Activate the plugin + +```dart +/// This Environment takes Firebase configuration from environment variables. +/// Pass environment variables to your app using the `--dart-define` flag. +class Environment with FirebaseCorePluginEnvironment { + const Environment(); + + /// The options of Firebase to pass to [Firebase.initializeApp]. + /// You can keep this null if you use the old google_services.json method. + /// If you want to support a web project as well, the [FirebaseCorePluginEnvironment.firebaseWebOptions] + /// getter is also available. + @override + Map? get firebaseOptions => { + TargetPlatform.android: DefaultFirebaseOptions.android, + /// etc... + }; +} + +void main() { + App( + environment: const Environment(), + plugins: [ + FirebaseCorePlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_firebase_core/LICENSE) \ No newline at end of file diff --git a/packages/patapata_firebase_core/analysis_options.yaml b/packages/patapata_firebase_core/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_firebase_core/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_firebase_core/dartdoc_options.yaml b/packages/patapata_firebase_core/dartdoc_options.yaml new file mode 100644 index 0000000..73b2ecf --- /dev/null +++ b/packages/patapata_firebase_core/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_firebase_core \ No newline at end of file diff --git a/packages/patapata_firebase_core/lib/patapata_firebase_core.dart b/packages/patapata_firebase_core/lib/patapata_firebase_core.dart new file mode 100644 index 0000000..20319cf --- /dev/null +++ b/packages/patapata_firebase_core/lib/patapata_firebase_core.dart @@ -0,0 +1,48 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_firebase_core; + +import 'dart:async'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:patapata_core/patapata_core.dart'; + +export 'package:firebase_core/firebase_core.dart' show FirebaseOptions; + +/// Configuration for Firebase in the application, used by [FirebaseCorePlugin]. +mixin FirebaseCorePluginEnvironment { + /// The name of Firebase to pass to [Firebase.initializeApp]. + String? get firebaseName => null; + + /// The options of Firebase to pass to [Firebase.initializeApp]. + Map? get firebaseOptions => null; + + /// The options for the web platform of Firebase to pass to [Firebase.initializeApp]. + FirebaseOptions? get firebaseWebOptions => null; +} + +/// A plugin that provides FirebaseCore functionality. +/// This plugin is required when adding Firebase to an application that uses Patapata. +class FirebaseCorePlugin extends Plugin { + @override + FutureOr init(App app) async { + await super.init(app); + + final tEnvironment = app.environment is FirebaseCorePluginEnvironment + ? app.environment as FirebaseCorePluginEnvironment + : null; + + await Firebase.initializeApp( + name: tEnvironment?.firebaseName, + options: kIsWeb + ? tEnvironment?.firebaseWebOptions + : tEnvironment?.firebaseOptions?[defaultTargetPlatform], + ); + + return true; + } +} diff --git a/packages/patapata_firebase_core/pubspec.yaml b/packages/patapata_firebase_core/pubspec.yaml new file mode 100644 index 0000000..bdd8c95 --- /dev/null +++ b/packages/patapata_firebase_core/pubspec.yaml @@ -0,0 +1,24 @@ +name: patapata_firebase_core +description: This package is a plugin for Patapata that adds support for Firebase to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_firebase_core + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + firebase_core: ^2.15.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: diff --git a/packages/patapata_firebase_core/test/patapata_firebase_core_test.dart b/packages/patapata_firebase_core/test/patapata_firebase_core_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_firebase_core/test/patapata_firebase_core_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_firebase_crashlytics/.gitignore b/packages/patapata_firebase_crashlytics/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_firebase_crashlytics/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_firebase_crashlytics/.metadata b/packages/patapata_firebase_crashlytics/.metadata new file mode 100644 index 0000000..4311ca2 --- /dev/null +++ b/packages/patapata_firebase_crashlytics/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: package diff --git a/packages/patapata_firebase_crashlytics/CHANGELOG.md b/packages/patapata_firebase_crashlytics/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_firebase_crashlytics/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_firebase_crashlytics/LICENSE b/packages/patapata_firebase_crashlytics/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_firebase_crashlytics/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_firebase_crashlytics/README.md b/packages/patapata_firebase_crashlytics/README.md new file mode 100644 index 0000000..5813054 --- /dev/null +++ b/packages/patapata_firebase_crashlytics/README.md @@ -0,0 +1,51 @@ +
    +

    Patapata - Firebase Crashlytics

    +

    + Add support for Firebase Crashlytics to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Firebase Crashlytics](https://firebase.google.com/docs/crashlytics/) to your Patapata app. +It will automatically report errors and logs to Firebase Crashlytics from Patapata's Log and Error systems. + +This plugin requires the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) plugin to be installed and activated. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_firebase_crashlytics +``` + +2. Import the package + +```dart +import 'package:patapata_firebase_crashlytics/patapata_firebase_crashlytics.dart'; +``` + +3. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + FirebaseCorePlugin(), + FirebaseCrashlyticsPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_firebase_crashlytics/LICENSE) diff --git a/packages/patapata_firebase_crashlytics/analysis_options.yaml b/packages/patapata_firebase_crashlytics/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_firebase_crashlytics/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_firebase_crashlytics/dartdoc_options.yaml b/packages/patapata_firebase_crashlytics/dartdoc_options.yaml new file mode 100644 index 0000000..ae9868f --- /dev/null +++ b/packages/patapata_firebase_crashlytics/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_firebase_crashlytics \ No newline at end of file diff --git a/packages/patapata_firebase_crashlytics/lib/patapata_firebase_crashlytics.dart b/packages/patapata_firebase_crashlytics/lib/patapata_firebase_crashlytics.dart new file mode 100644 index 0000000..1500a45 --- /dev/null +++ b/packages/patapata_firebase_crashlytics/lib/patapata_firebase_crashlytics.dart @@ -0,0 +1,110 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_firebase_crashlytics; + +import 'package:flutter/foundation.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_firebase_core/patapata_firebase_core.dart'; +import 'package:stack_trace/stack_trace.dart'; + +/// This is a plugin that provides FirebaseCrashlytics functionality to Patapata. +/// To use this plugin, you need to add the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) package to your application. +class FirebaseCrashlyticsPlugin extends Plugin { + static const _exceptionLogPrefix = '[EXCEPTION]'; + StreamSubscription? _onReportSubscription; + + @override + List get dependencies => [FirebaseCorePlugin]; + + /// Initialize [FirebaseCrashlyticsPlugin]. + @override + FutureOr init(App app) async { + if (kIsWeb) { + // It's not working right now. + return false; + } + + await super.init(app); + + _onReportSubscription = app.log.reports.listen(_onReport); + + app.user.addSynchronousChangeListener(_onUserChanged); + + return true; + } + + @override + FutureOr dispose() async { + app.user.removeSynchronousChangeListener(_onUserChanged); + await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); + + await super.dispose(); + await _onReportSubscription?.cancel(); + + _onReportSubscription = null; + } + + void _onReport(ReportRecord record) async { + if (record.level < Level.WARNING) { + await FirebaseCrashlytics.instance.log(record.toString()); + + return; + } + + if (record.object is FlutterErrorDetails) { + FirebaseCrashlytics.instance + .recordFlutterError(record.object as FlutterErrorDetails); + + return; + } + + FirebaseCrashlytics.instance.log(_exceptionLogPrefix + record.message); + + StackTrace? tStackTrace = record.stackTrace; + + if (tStackTrace is Chain) { + tStackTrace = tStackTrace.toTrace(); + } + + if (tStackTrace is Trace) { + tStackTrace = tStackTrace.vmTrace; + } + + await FirebaseCrashlytics.instance.recordError( + record.error ?? record.object ?? record.message, + tStackTrace == null || tStackTrace == StackTrace.empty + ? Trace.current(1) + : tStackTrace, + reason: record.message, + printDetails: false, + ); + } + + String? _lastId; + + FutureOr _onUserChanged(User user, UserChangeData changes) async { + final tId = changes.getIdFor(); + final tProperties = changes.getPropertiesFor(); + + if (tId != _lastId) { + _lastId = tId; + await FirebaseCrashlytics.instance.setUserIdentifier(tId ?? ''); + } + + await Future.wait([ + for (var i in tProperties.entries) + FirebaseCrashlytics.instance.setCustomKey(i.key, i.value ?? ''), + ]); + } + + /// Sends unsent crash reports to Firebase. + /// For details, refer to [FirebaseCrashlytics.sendUnsentReports]. + Future sendUnsentReports() async { + await FirebaseCrashlytics.instance.sendUnsentReports(); + } +} diff --git a/packages/patapata_firebase_crashlytics/pubspec.yaml b/packages/patapata_firebase_crashlytics/pubspec.yaml new file mode 100644 index 0000000..e27dbfd --- /dev/null +++ b/packages/patapata_firebase_crashlytics/pubspec.yaml @@ -0,0 +1,27 @@ +name: patapata_firebase_crashlytics +description: This package is a plugin for Patapata that adds support for Firebase Crashlytics to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_firebase_crashlytics + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + patapata_firebase_core: ^1.0.0 + + firebase_crashlytics: ^3.3.5 + stack_trace: ^1.11.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: diff --git a/packages/patapata_firebase_crashlytics/test/patapata_firebase_crashlytics_test.dart b/packages/patapata_firebase_crashlytics/test/patapata_firebase_crashlytics_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_firebase_crashlytics/test/patapata_firebase_crashlytics_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_firebase_dynamic_links/.gitignore b/packages/patapata_firebase_dynamic_links/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_firebase_dynamic_links/.metadata b/packages/patapata_firebase_dynamic_links/.metadata new file mode 100644 index 0000000..11b94d3 --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 6928314d505d2bb4777be05e45d7808a5aa91d2a + channel: unknown + +project_type: package diff --git a/packages/patapata_firebase_dynamic_links/CHANGELOG.md b/packages/patapata_firebase_dynamic_links/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_firebase_dynamic_links/LICENSE b/packages/patapata_firebase_dynamic_links/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_firebase_dynamic_links/README.md b/packages/patapata_firebase_dynamic_links/README.md new file mode 100644 index 0000000..94ee82c --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/README.md @@ -0,0 +1,58 @@ +
    +

    Patapata - Firebase Dynamic Links

    +

    + Add support for Firebase Dynamic Links to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Firebase Dynamic Links](https://firebase.google.com/docs/dynamic-links/) to your Patapata app. + +It will automatically handle dynamic links from Firebase and send them to Patapata's Link system. + +This plugin requires the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) plugin to be installed and activated. + +--- + +***Firebase Dynamic Links is deprecated by Google*** + +Patapata and StandardApp supports features like Apple's Universal Links and Android's App Links, which also provide the ability to open your app from a link. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_firebase_dynamic_links +``` + +2. Import the package + +```dart +import 'package:patapata_firebase_dynamic_links/patapata_firebase_dynamic_links.dart'; +``` + +3. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + FirebaseCorePlugin(), + FirebaseDynamicLinksPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_firebase_dynamic_links/LICENSE) diff --git a/packages/patapata_firebase_dynamic_links/analysis_options.yaml b/packages/patapata_firebase_dynamic_links/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_firebase_dynamic_links/dartdoc_options.yaml b/packages/patapata_firebase_dynamic_links/dartdoc_options.yaml new file mode 100644 index 0000000..ed79305 --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_firebase_dynamic_links \ No newline at end of file diff --git a/packages/patapata_firebase_dynamic_links/lib/patapata_firebase_dynamic_links.dart b/packages/patapata_firebase_dynamic_links/lib/patapata_firebase_dynamic_links.dart new file mode 100644 index 0000000..711517d --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/lib/patapata_firebase_dynamic_links.dart @@ -0,0 +1,180 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_firebase_dynamic_links; + +import 'dart:async'; + +import 'package:firebase_dynamic_links/firebase_dynamic_links.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_firebase_core/patapata_firebase_core.dart'; + +/// This is a plugin that provides functionality for Firebase Dynamic Links. +/// This plugin requires adding the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) package to your application. +class FirebaseDynamicLinksPlugin extends Plugin + with StandardAppRoutePluginMixin { + StreamSubscription? _onLinkSubscription; + PendingDynamicLinkData? _initialData; + + @override + List get dependencies => [FirebaseCorePlugin]; + + /// Initializes the [FirebaseDynamicLinksPlugin]. + /// Please note that Dynamic Links are not supported on the web. + @override + FutureOr init(App app) async { + if (kIsWeb) { + // It's not working right now. + return false; + } + + await super.init(app); + + _initialData = await FirebaseDynamicLinks.instance.getInitialLink(); + _onLinkSubscription = FirebaseDynamicLinks.instance.onLink.listen(_onLink); + + return true; + } + + @override + FutureOr dispose() { + _onLinkSubscription?.cancel(); + _onLinkSubscription = null; + + return super.dispose(); + } + + /// Retrieves the necessary [StandardRouteData] for the initial screen transition to the `Router`. + @override + Future getInitialRouteData() { + if (_initialData == null) { + return SynchronousFuture(null); + } + + final tParser = app.getPlugin()?.parser; + + if (tParser == null) { + return SynchronousFuture(null); + } + + return tParser.parseRouteInformation( + RouteInformation( + uri: _initialData!.link, + ), + ); + } + + void _onLink(PendingDynamicLinkData data) async { + final tPlugin = app.getPlugin(); + + final tParser = tPlugin?.parser; + + if (tParser == null) { + return; + } + + final tConfiguration = await tParser.parseRouteInformation( + RouteInformation( + uri: data.link, + ), + ); + + tPlugin?.delegate?.routeWithConfiguration(tConfiguration); + } + + /// Generates an external link or shortened external link for [FirebaseDynamicLinks]. + Future generateExternalLink({ + required Uri link, + required String uriPrefix, + required String androidPackageName, + required String iOSBundleId, + required String iOSAppStoreId, + String? googleAnalyticsCampaign, + String? googleAnalyticsContent, + String? googleAnalyticsMedium, + String? googleAnalyticsSource, + String? googleAnalyticsTerm, + String? itunesConnectAnalyticsAffiliateToken, + String? itunesConnectAnalyticsCampaignToken, + String? itunesConnectAnalyticsProviderToken, + String? description, + Uri? image, + String? title, + }) async { + var tLink = await FirebaseDynamicLinks.instance.buildLink( + DynamicLinkParameters( + link: link, + uriPrefix: uriPrefix, + androidParameters: AndroidParameters( + packageName: androidPackageName, + ), + iosParameters: IOSParameters( + bundleId: iOSBundleId, + appStoreId: iOSAppStoreId, + ), + googleAnalyticsParameters: GoogleAnalyticsParameters( + campaign: googleAnalyticsCampaign, + content: googleAnalyticsContent, + medium: googleAnalyticsMedium, + source: googleAnalyticsSource, + term: googleAnalyticsTerm, + ), + itunesConnectAnalyticsParameters: ITunesConnectAnalyticsParameters( + affiliateToken: itunesConnectAnalyticsAffiliateToken, + campaignToken: itunesConnectAnalyticsCampaignToken, + providerToken: itunesConnectAnalyticsProviderToken, + ), + navigationInfoParameters: const NavigationInfoParameters( + forcedRedirectEnabled: true, + ), + socialMetaTagParameters: SocialMetaTagParameters( + description: description, + imageUrl: image, + title: title, + ), + ), + ); + + return (await FirebaseDynamicLinks.instance.buildShortLink( + DynamicLinkParameters( + link: link, + uriPrefix: uriPrefix, + longDynamicLink: tLink, + androidParameters: AndroidParameters( + packageName: androidPackageName, + ), + iosParameters: IOSParameters( + bundleId: iOSBundleId, + appStoreId: iOSAppStoreId, + ), + googleAnalyticsParameters: GoogleAnalyticsParameters( + campaign: googleAnalyticsCampaign, + content: googleAnalyticsContent, + medium: googleAnalyticsMedium, + source: googleAnalyticsSource, + term: googleAnalyticsTerm, + ), + itunesConnectAnalyticsParameters: ITunesConnectAnalyticsParameters( + affiliateToken: itunesConnectAnalyticsAffiliateToken, + campaignToken: itunesConnectAnalyticsCampaignToken, + providerToken: itunesConnectAnalyticsProviderToken, + ), + navigationInfoParameters: const NavigationInfoParameters( + forcedRedirectEnabled: true, + ), + socialMetaTagParameters: SocialMetaTagParameters( + description: description, + imageUrl: image, + title: title, + ), + ), + )) + .shortUrl + .toString(); + } +} diff --git a/packages/patapata_firebase_dynamic_links/pubspec.yaml b/packages/patapata_firebase_dynamic_links/pubspec.yaml new file mode 100644 index 0000000..905cd46 --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/pubspec.yaml @@ -0,0 +1,27 @@ +name: patapata_firebase_dynamic_links +description: This package is a plugin for Patapata that adds support for Firebase Dynamic Links to your Patapata app. +version: 1.0.0 +publish_to: none +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_firebase_dynamic_links + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + patapata_firebase_core: ^1.0.0 + + firebase_dynamic_links: ^5.3.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: diff --git a/packages/patapata_firebase_dynamic_links/test/patapata_firebase_dynamic_links_test.dart b/packages/patapata_firebase_dynamic_links/test/patapata_firebase_dynamic_links_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_firebase_dynamic_links/test/patapata_firebase_dynamic_links_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_firebase_messaging/.gitignore b/packages/patapata_firebase_messaging/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_firebase_messaging/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_firebase_messaging/.metadata b/packages/patapata_firebase_messaging/.metadata new file mode 100644 index 0000000..4311ca2 --- /dev/null +++ b/packages/patapata_firebase_messaging/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: package diff --git a/packages/patapata_firebase_messaging/CHANGELOG.md b/packages/patapata_firebase_messaging/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_firebase_messaging/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_firebase_messaging/LICENSE b/packages/patapata_firebase_messaging/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_firebase_messaging/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_firebase_messaging/README.md b/packages/patapata_firebase_messaging/README.md new file mode 100644 index 0000000..6e6a34c --- /dev/null +++ b/packages/patapata_firebase_messaging/README.md @@ -0,0 +1,51 @@ +
    +

    Patapata - Firebase Cloud Messaging

    +

    + Add support for Firebase Cloud Messaging to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging/) to your Patapata app. +It will automatically handle push notifications from Firebase and send them to Patapata's Notification system. + +This plugin requires the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) plugin to be installed and activated. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_firebase_messaging +``` + +2. Import the package + +```dart +import 'package:patapata_firebase_messaging/patapata_firebase_messaging.dart'; +``` + +3. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + FirebaseCorePlugin(), + FirebaseMessagingPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_firebase_messaging/LICENSE) diff --git a/packages/patapata_firebase_messaging/analysis_options.yaml b/packages/patapata_firebase_messaging/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_firebase_messaging/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_firebase_messaging/android/.gitignore b/packages/patapata_firebase_messaging/android/.gitignore new file mode 100644 index 0000000..c6cbe56 --- /dev/null +++ b/packages/patapata_firebase_messaging/android/.gitignore @@ -0,0 +1,8 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures diff --git a/packages/patapata_firebase_messaging/android/build.gradle b/packages/patapata_firebase_messaging/android/build.gradle new file mode 100644 index 0000000..7a602f0 --- /dev/null +++ b/packages/patapata_firebase_messaging/android/build.gradle @@ -0,0 +1,51 @@ +group 'dev.patapata.patapata_firebase_messaging' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 33 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 16 + targetSdkVersion 33 + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/packages/patapata_firebase_messaging/android/settings.gradle b/packages/patapata_firebase_messaging/android/settings.gradle new file mode 100644 index 0000000..68d3709 --- /dev/null +++ b/packages/patapata_firebase_messaging/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'patapata_firebase_messaging' diff --git a/packages/patapata_firebase_messaging/android/src/main/AndroidManifest.xml b/packages/patapata_firebase_messaging/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2a9ee9 --- /dev/null +++ b/packages/patapata_firebase_messaging/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/patapata_firebase_messaging/android/src/main/kotlin/dev/patapata/patapata_firebase_messaging/PatapataFirebaseMessagingPlugin.kt b/packages/patapata_firebase_messaging/android/src/main/kotlin/dev/patapata/patapata_firebase_messaging/PatapataFirebaseMessagingPlugin.kt new file mode 100644 index 0000000..efccb7f --- /dev/null +++ b/packages/patapata_firebase_messaging/android/src/main/kotlin/dev/patapata/patapata_firebase_messaging/PatapataFirebaseMessagingPlugin.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package dev.patapata.patapata_firebase_messaging + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.NonNull +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry + +/** PatapataFirebaseMessagingPlugin */ +class PatapataFirebaseMessagingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, PluginRegistry.RequestPermissionsResultListener { + private lateinit var mChannel : MethodChannel + private lateinit var mBinding : FlutterPlugin.FlutterPluginBinding + private lateinit var mActivity : Activity + private lateinit var mPluginBinding : ActivityPluginBinding + private var mResult: Result? = null + + companion object { + const val PERMISSION_REQUEST_CODE = 1001 + } + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + mBinding = flutterPluginBinding + mChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "dev.patapata.patapata_firebase_messaging") + mChannel.setMethodCallHandler(this) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "requestPermission" -> { + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + result.success(true) + } else { + val tResult: Int = ContextCompat.checkSelfPermission( + mBinding.applicationContext, Manifest.permission.POST_NOTIFICATIONS) + + if (tResult == PackageManager.PERMISSION_DENIED) { + ActivityCompat.requestPermissions( + mActivity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + PERMISSION_REQUEST_CODE + ) + // このタイミングでユーザが許可したかどうかは判定されないため,onRequestPermissionsResultで実行する + mResult = result + } else { + // 既に通知許可されている場合 + result.success(true) + } + } + } + else -> { + result.notImplemented() + } + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray): Boolean { + // 通知許可のダイアログを出した後、許可・許可しないの選択をした時の処理 + if (requestCode == PERMISSION_REQUEST_CODE) { + val tIsSuccess : Boolean = (grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED) + mResult?.success(tIsSuccess) + mResult = null + return tIsSuccess + } + + return false + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + mChannel.setMethodCallHandler(null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + mPluginBinding = binding + mActivity = binding.activity + mPluginBinding.addRequestPermissionsResultListener(this) + } + + override fun onDetachedFromActivityForConfigChanges() { + + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + + } + + override fun onDetachedFromActivity() { + mPluginBinding.removeRequestPermissionsResultListener(this) + } +} diff --git a/packages/patapata_firebase_messaging/dartdoc_options.yaml b/packages/patapata_firebase_messaging/dartdoc_options.yaml new file mode 100644 index 0000000..1cad413 --- /dev/null +++ b/packages/patapata_firebase_messaging/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_firebase_messaging \ No newline at end of file diff --git a/packages/patapata_firebase_messaging/lib/patapata_firebase_messaging.dart b/packages/patapata_firebase_messaging/lib/patapata_firebase_messaging.dart new file mode 100644 index 0000000..079a65f --- /dev/null +++ b/packages/patapata_firebase_messaging/lib/patapata_firebase_messaging.dart @@ -0,0 +1,472 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_firebase_messaging; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:crypto/crypto.dart'; + +import 'package:firebase_messaging/firebase_messaging.dart' as firebase; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_firebase_core/patapata_firebase_core.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +final _logger = Logger('patapata.FirebaseMessagingPlugin'); + +const int _kLocalNotificationIdBase = kPataInHex; + +/// Generate a random notification ID number to avoid conflicting with any other Intent on the system (hopefully) +/// This has to be less than a 32 bit signed int. +int _generateLocalNotificationId() { + return _kLocalNotificationIdBase + + Random().nextInt(_kLocalNotificationIdBase >> 8); +} + +/// A plugin that provides Firebase Cloud Messaging functionality to Patapata. +/// This plugin requires adding the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) package to your application. +class FirebaseMessagingPlugin extends Plugin with StandardAppRoutePluginMixin { + /// A reference to the [firebase.FirebaseMessaging] instance. + late final firebase.FirebaseMessaging instance; + + final _selectedMessagesController = + StreamController.broadcast(); + + @override + List get dependencies => [ + FirebaseCorePlugin, + NotificationsPlugin, + ]; + + /// Initializes the [FirebaseMessagingPlugin]. + @override + FutureOr init(App app) async { + await super.init(app); + + instance = firebase.FirebaseMessaging.instance; + + if (!await instance.isSupported()) { + _logger.info('Firebase Messaging not supported. Disabling.'); + + return false; + } + + firebase.FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage); + + return true; + } + + @override + FutureOr dispose() async { + await super.dispose(); + + // Can't disable a background message handler it seems. + + instance.setAutoInitEnabled(false); + } + + @override + RemoteMessaging createRemoteMessaging() => + FirebaseMessagingRemoteMessaging(this); + + /// Retrieve the Future of the first Firebase remote message. + Future getInitialMessage() async { + final tFirebaseInitialMessage = await instance.getInitialMessage(); + + return tFirebaseInitialMessage != null + ? FirebaseMessagingRemoteMessagingRemoteMessage.fromFirebase( + tFirebaseInitialMessage) + : null; + } + + @override + Future getInitialRouteData() async { + try { + final tRemoteMessage = await getInitialMessage(); + + if (tRemoteMessage == null) { + return null; + } + + final tLocation = tRemoteMessage + .data?[app.getPlugin()?.payloadLocationKey] + as String?; + + if (tLocation?.isNotEmpty == true) { + final tParser = app.getPlugin()?.parser; + + if (tParser == null) { + return null; + } + + final tLocationUri = Uri.parse(tLocation!); + + return tParser.parseRouteInformation( + RouteInformation( + uri: tLocationUri, + ), + ); + } + } catch (e) { + // ignore + } + + return null; + } + + Stream get _selectedMessages => + _selectedMessagesController.stream; +} + +/// The actual [RemoteMessage] that [FirebaseMessagingRemoteMessaging] uses to exchange messages with Firebase. +class FirebaseMessagingRemoteMessagingRemoteMessage extends RemoteMessage { + /// A reference to the [firebase.RemoteMessage] instance. + final firebase.RemoteMessage firebaseRemoteMessage; + + /// Creates a Firebase remote message for the provided argument [firebaseRemoteMessage]. + /// Optionally, provide [messageId] as a unique ID assigned to each message, [channel] for each message's channel, + /// [data] as a Map for received data, and [notification] of [RemoteMessageNotification] with title and body information when receiving the notification. + const FirebaseMessagingRemoteMessagingRemoteMessage({ + required this.firebaseRemoteMessage, + String? messageId, + String? channel, + Map? data, + RemoteMessageNotification? notification, + }) : super( + messageId: messageId, + data: data, + notification: notification, + ); + + /// A factory class that creates an instance of RemoteMessage from a Firebase remote message [message]. + factory FirebaseMessagingRemoteMessagingRemoteMessage.fromFirebase( + firebase.RemoteMessage message, + ) { + final tNotification = message.notification; + + return FirebaseMessagingRemoteMessagingRemoteMessage( + firebaseRemoteMessage: message, + messageId: message.messageId, + channel: message.from ?? RemoteMessage.kRemoteMessageDefaultChannel, + data: message.data, + notification: tNotification != null + ? RemoteMessageNotification( + title: tNotification.title, + body: tNotification.body, + ) + : null, + ); + } +} + +/// A class for monitoring the remote messages of [FirebaseMessagingRemoteMessagingRemoteMessage]. +class FirebaseMessagingRemoteMessaging extends RemoteMessaging { + final FirebaseMessagingPlugin _instance; + + StreamSubscription? _onMessageSubscription; + StreamSubscription? _onMessageOpenedAppSubscription; + StreamSubscription? _onSelectMessageSubscription; + StreamSubscription? _onTokenRefreshSubscription; + + final _messagesController = StreamController.broadcast(); + final _tokensController = StreamController.broadcast(); + + /// Constructor for [FirebaseMessagingRemoteMessaging]. + FirebaseMessagingRemoteMessaging(this._instance); + + /// Initializes the [FirebaseMessagingRemoteMessaging]. + @override + Future init(App app) async { + await super.init(app); + + _onMessageSubscription = + firebase.FirebaseMessaging.onMessage.listen(_onMessage); + _onMessageOpenedAppSubscription = firebase + .FirebaseMessaging.onMessageOpenedApp + .listen(_onMessageOpenedApp); + _onSelectMessageSubscription = + _instance._selectedMessages.listen(_onSelectMessage); + _onTokenRefreshSubscription = + firebase.FirebaseMessaging.instance.onTokenRefresh.listen(_onToken); + } + + @override + void dispose() { + _onMessageSubscription?.cancel(); + _onMessageSubscription = null; + _onMessageOpenedAppSubscription?.cancel(); + _onMessageOpenedAppSubscription = null; + _onSelectMessageSubscription?.cancel(); + _onSelectMessageSubscription = null; + _onTokenRefreshSubscription?.cancel(); + _onTokenRefreshSubscription = null; + super.dispose(); + } + + void _onSelectMessage(RemoteMessage message) { + _messagesController.add(message); + } + + void _onMessage(firebase.RemoteMessage message) async { + _logger.info( + '_onMessage:{messageId:${message.messageId} from:${message.from}, data:${message.data}}'); + + await _showNotificationFromFirebaseRemoteMessage(message); + } + + void _onMessageOpenedApp(firebase.RemoteMessage message) async { + _logger.info( + '_onMessageOpenedApp:{messageId:${message.messageId} from:${message.from}, data:${message.data}}'); + + final tRemoteMessage = + FirebaseMessagingRemoteMessagingRemoteMessage.fromFirebase(message); + + if (app.hasPlugin(StandardAppPlugin)) { + try { + final tLocation = tRemoteMessage + .data?[app.getPlugin()?.payloadLocationKey] + as String?; + + if (tLocation?.isNotEmpty == true) { + app.getPlugin()!.route(tLocation!); + } + } catch (e) { + // ignore + } + } + + _messagesController.add(tRemoteMessage); + } + + void _onToken(String token) { + _logger.info('_onToken:$token'); + + _tokensController.add(token); + } + + @override + Future getInitialMessage() { + return _instance.getInitialMessage(); + } + + @override + Stream get messages => _messagesController.stream; + + @override + Stream get tokens => _tokensController.stream; + + @override + Future getToken() async { + try { + return _instance.instance.getToken(); + } catch (e, stackTrace) { + _logger.info('Failed get token for Firebase Messaginging', e, stackTrace); + return null; + } + } + + @override + FutureOr listenChannel(String channel) async { + if (!await super.listenChannel(channel)) { + return false; + } + + try { + await _instance.instance.subscribeToTopic(channel); + } catch (e, stackTrace) { + _logger.warning( + 'Could not subscribe to Firebase Messaging topic: $channel', + e, + stackTrace); + + await super.ignoreChannel(channel); + + return false; + } + + return true; + } + + @override + FutureOr ignoreChannel(String channel) async { + if (!await super.ignoreChannel(channel)) { + return false; + } + + try { + await _instance.instance.unsubscribeFromTopic(channel); + } catch (e, stackTrace) { + _logger.warning( + 'Could not unsubscribe Firebase Messaging topic: $channel', + e, + stackTrace); + + return false; + } + + return true; + } +} + +Future?> _getAndroidBitmapFromUrl(String url) async { + HttpClient? tClient; + IOSink? tSync; + + try { + final tDirectory = await getApplicationDocumentsDirectory(); + final tUri = Uri.tryParse(url); + + if (tUri != null && await tDirectory.exists()) { + tClient = HttpClient(); + final tRequest = await tClient.getUrl(tUri); + final tResponse = await tRequest.close(); + + if (tResponse.statusCode == HttpStatus.ok) { + final tFile = File( + '${tDirectory.path}${Platform.pathSeparator}${md5.convert(utf8.encode(url)).toString()}'); + tSync = tFile.openWrite(); + + await tSync.addStream(tResponse); + + return FilePathAndroidBitmap(tFile.path); + } + } + } catch (e, stackTrace) { + _logger.info('Failed to download bitmap for notification.', e, stackTrace); + } finally { + try { + tSync?.close(); + } catch (e) { + // ignore + } + + try { + tClient?.close(); + } catch (e) { + // ignore + } + } + + return null; +} + +Future _showNotificationFromFirebaseRemoteMessage( + firebase.RemoteMessage message, { + bool fromBackground = false, +}) async { + if (fromBackground && defaultTargetPlatform != TargetPlatform.android) { + // Only Android needs to manually make a notification... + return; + } + + final tNotification = message.notification; + final String tPayload; + + if (tNotification != null && fromBackground) { + // Firebase will show it for us. + return; + } + + try { + tPayload = jsonEncode(message.data); + } catch (e, stackTrace) { + _logger.fine('Bad payload', e, stackTrace); + return; + } + + if (tNotification != null) { + final tImageUrl = tNotification.android?.imageUrl; + AndroidBitmap? tImage; + + if (tImageUrl?.isNotEmpty == true) { + tImage = await _getAndroidBitmapFromUrl(tImageUrl!); + } + + await FlutterLocalNotificationsPlugin().show( + _generateLocalNotificationId(), + tNotification.title, + tNotification.body, + NotificationDetails( + android: AndroidNotificationDetails( + tNotification.android?.channelId ?? + NotificationsPlugin.kDefaultAndroidChannel.id, + tNotification.android?.channelId ?? + NotificationsPlugin.kDefaultAndroidChannel.name, + styleInformation: tImage != null + ? BigPictureStyleInformation(tImage) + : BigTextStyleInformation(tNotification.body ?? ''), + largeIcon: tImage, + ), + ), + payload: tPayload, + ); + + if (tImage != null) { + try { + await File(tImage.data).delete(); + } catch (e, stackTrace) { + _logger.info('Failed to delete just created image.', e, stackTrace); + } + } + } else { + // handle an internal project's style... + // ignore: todo + // TODO: Temp... make this generic + final tData = message.data; + + if ((tData['notifyType'] as String?) != 'URL') { + return; + } + + final tImageUrl = tData['mainImageUri'] as String?; + AndroidBitmap? tImage; + + if (tImageUrl?.isNotEmpty == true) { + tImage = await _getAndroidBitmapFromUrl(tImageUrl!); + } + + await FlutterLocalNotificationsPlugin().show( + _generateLocalNotificationId(), + tData['title'] as String?, + tData['text'] as String?, + NotificationDetails( + android: AndroidNotificationDetails( + NotificationsPlugin.kDefaultAndroidChannel.id, + NotificationsPlugin.kDefaultAndroidChannel.name, + styleInformation: tImage != null + ? BigPictureStyleInformation(tImage) + : BigTextStyleInformation((tData['text'] as String?) ?? ''), + largeIcon: tImage, + ticker: tData['tickerText'] as String?, + ), + ), + payload: tPayload, + ); + + if (tImage != null) { + try { + await File(tImage.data).delete(); + } catch (e, stackTrace) { + _logger.info('Failed to delete just created image.', e, stackTrace); + } + } + } +} + +/// This function will be executed in a background isolate +@pragma('vm:entry-point') +Future _onBackgroundMessage(firebase.RemoteMessage message) async { + WidgetsFlutterBinding.ensureInitialized(); + NotificationsPlugin.initializeNotificationsForBackgroundIsolate(); + await _showNotificationFromFirebaseRemoteMessage(message, + fromBackground: true); +} diff --git a/packages/patapata_firebase_messaging/pubspec.yaml b/packages/patapata_firebase_messaging/pubspec.yaml new file mode 100644 index 0000000..6e6efc5 --- /dev/null +++ b/packages/patapata_firebase_messaging/pubspec.yaml @@ -0,0 +1,34 @@ +name: patapata_firebase_messaging +description: This package is a plugin for Patapata that adds support for Firebase Cloud Messaging to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_firebase_messaging + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + patapata_firebase_core: ^1.0.0 + + firebase_messaging: ^14.6.6 + path_provider: ">=2.0.9 <3.0.0" + crypto: ">=3.0.3 <4.0.0" + flutter_local_notifications: ^15.1.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: + plugin: + platforms: + android: + package: dev.patapata.patapata_firebase_messaging + pluginClass: PatapataFirebaseMessagingPlugin diff --git a/packages/patapata_firebase_messaging/test/patapata_firebase_messaging_test.dart b/packages/patapata_firebase_messaging/test/patapata_firebase_messaging_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_firebase_messaging/test/patapata_firebase_messaging_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_firebase_remote_config/.gitignore b/packages/patapata_firebase_remote_config/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_firebase_remote_config/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_firebase_remote_config/.metadata b/packages/patapata_firebase_remote_config/.metadata new file mode 100644 index 0000000..4311ca2 --- /dev/null +++ b/packages/patapata_firebase_remote_config/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: package diff --git a/packages/patapata_firebase_remote_config/CHANGELOG.md b/packages/patapata_firebase_remote_config/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_firebase_remote_config/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_firebase_remote_config/LICENSE b/packages/patapata_firebase_remote_config/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_firebase_remote_config/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_firebase_remote_config/README.md b/packages/patapata_firebase_remote_config/README.md new file mode 100644 index 0000000..04c149c --- /dev/null +++ b/packages/patapata_firebase_remote_config/README.md @@ -0,0 +1,51 @@ +
    +

    Patapata - Firebase Remote Config

    +

    + Add support for Firebase Remote Config to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Firebase Remote Config](https://firebase.google.com/docs/remote-config/) to your Patapata app. +It will automatically fetch remote config values from Firebase and send them to Patapata's RemoteConfig system. + +This plugin requires the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) plugin to be installed and activated. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_firebase_remote_config +``` + +2. Import the package + +```dart +import 'package:patapata_firebase_remote_config/patapata_firebase_remote_config.dart'; +``` + +3. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + FirebaseCorePlugin(), + FirebaseRemoteConfigPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_firebase_remote_config/LICENSE) diff --git a/packages/patapata_firebase_remote_config/analysis_options.yaml b/packages/patapata_firebase_remote_config/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_firebase_remote_config/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_firebase_remote_config/dartdoc_options.yaml b/packages/patapata_firebase_remote_config/dartdoc_options.yaml new file mode 100644 index 0000000..0663a69 --- /dev/null +++ b/packages/patapata_firebase_remote_config/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_firebase_remote_config \ No newline at end of file diff --git a/packages/patapata_firebase_remote_config/lib/patapata_firebase_remote_config.dart b/packages/patapata_firebase_remote_config/lib/patapata_firebase_remote_config.dart new file mode 100644 index 0000000..6ed7181 --- /dev/null +++ b/packages/patapata_firebase_remote_config/lib/patapata_firebase_remote_config.dart @@ -0,0 +1,160 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_firebase_remote_config; + +import 'package:firebase_remote_config/firebase_remote_config.dart' + as firebase_remote_config; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:patapata_firebase_core/patapata_firebase_core.dart'; + +final _logger = Logger('patapata.FirebaseRemoteConfigPlugin'); + +/// This is a plugin that provides FirebaseRemoteConfig functionality to Patapata. +/// To use this plugin, you need to add the [patapata_firebase_core](https://pub.dev/packages/patapata_firebase_core) package to your application. +class FirebaseRemoteConfigPlugin extends Plugin { + @override + List get dependencies => [FirebaseCorePlugin]; + + @override + RemoteConfig createRemoteConfig() => + _FirebaseRemoteConfigPluginRemoteConfig(); +} + +class _FirebaseRemoteConfigPluginRemoteConfig extends RemoteConfig { + late firebase_remote_config.FirebaseRemoteConfig _remoteConfig; + StreamSubscription? _subscription; + + @override + Future init() async { + bool tIsDebug = false; + + assert(() { + tIsDebug = true; + return true; + }()); + + _remoteConfig = firebase_remote_config.FirebaseRemoteConfig.instance; + + if (tIsDebug) { + await _remoteConfig.setConfigSettings( + firebase_remote_config.RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: const Duration(minutes: 1), + ), + ); + _logger.finer('Enabled debug mode. Fetches will be more frequent!'); + } + + _subscription = _remoteConfig.onConfigUpdated.listen(_onRemoteChanged); + await super.init(); + } + + @override + void dispose() { + super.dispose(); + + _subscription?.cancel(); + _subscription = null; + } + + void _onRemoteChanged(firebase_remote_config.RemoteConfigUpdate update) { + onChange(); + } + + @override + Future fetch({ + Duration expiration = const Duration(hours: 5), + bool force = false, + }) async { + _logger.fine('fetch:$expiration'); + + try { + if (force) { + _logger.info('force fetch:$expiration'); + await _remoteConfig.setConfigSettings( + firebase_remote_config.RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: Duration.zero, + ), + ); + await _remoteConfig.fetchAndActivate(); + await _remoteConfig.setConfigSettings( + firebase_remote_config.RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: expiration, + ), + ); + } else { + await _remoteConfig.setConfigSettings( + firebase_remote_config.RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: expiration, + ), + ); + await _remoteConfig.fetchAndActivate(); + } + } catch (e, stackTrace) { + _logger.info('Failed to fetch', e, stackTrace); + } + } + + @override + bool getBool(String key, {bool defaultValue = Config.defaultValueForBool}) { + final tValue = _remoteConfig.getValue(key); + + if (tValue.source == firebase_remote_config.ValueSource.valueStatic) { + return defaultValue; + } + + return tValue.asBool(); + } + + @override + double getDouble(String key, + {double defaultValue = Config.defaultValueForDouble}) { + final tValue = _remoteConfig.getValue(key); + + if (tValue.source == firebase_remote_config.ValueSource.valueStatic) { + return defaultValue; + } + + return tValue.asDouble(); + } + + @override + int getInt(String key, {int defaultValue = Config.defaultValueForInt}) { + final tValue = _remoteConfig.getValue(key); + + if (tValue.source == firebase_remote_config.ValueSource.valueStatic) { + return defaultValue; + } + + return tValue.asInt(); + } + + @override + String getString(String key, + {String defaultValue = Config.defaultValueForString}) { + final tValue = _remoteConfig.getValue(key); + + if (tValue.source == firebase_remote_config.ValueSource.valueStatic) { + return defaultValue; + } + + return tValue.asString(); + } + + @override + bool hasKey(String key) => + _remoteConfig.getValue(key).source == + firebase_remote_config.ValueSource.valueRemote; + + @override + Future setDefaults(Map defaults) async { + await _remoteConfig.setDefaults(defaults); + } +} diff --git a/packages/patapata_firebase_remote_config/pubspec.yaml b/packages/patapata_firebase_remote_config/pubspec.yaml new file mode 100644 index 0000000..3578e74 --- /dev/null +++ b/packages/patapata_firebase_remote_config/pubspec.yaml @@ -0,0 +1,26 @@ +name: patapata_firebase_remote_config +description: This package is a plugin for Patapata that adds support for Firebase Remote Config to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_firebase_remote_config + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + patapata_firebase_core: ^1.0.0 + + firebase_remote_config: ^4.2.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: diff --git a/packages/patapata_firebase_remote_config/test/patapata_firebase_remote_config_test.dart b/packages/patapata_firebase_remote_config/test/patapata_firebase_remote_config_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_firebase_remote_config/test/patapata_firebase_remote_config_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_karte_core/.gitignore b/packages/patapata_karte_core/.gitignore new file mode 100644 index 0000000..7c2eea5 --- /dev/null +++ b/packages/patapata_karte_core/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +.dart_tool/ + +.packages +.pub/ + +build/ + +**/doc/api/ \ No newline at end of file diff --git a/packages/patapata_karte_core/.metadata b/packages/patapata_karte_core/.metadata new file mode 100644 index 0000000..eb5f3e0 --- /dev/null +++ b/packages/patapata_karte_core/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: plugin diff --git a/packages/patapata_karte_core/CHANGELOG.md b/packages/patapata_karte_core/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_karte_core/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_karte_core/LICENSE b/packages/patapata_karte_core/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_karte_core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_karte_core/README.md b/packages/patapata_karte_core/README.md new file mode 100644 index 0000000..ac316df --- /dev/null +++ b/packages/patapata_karte_core/README.md @@ -0,0 +1,68 @@ +
    +

    Patapata - Karte Core

    +

    + Add support for Karte to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Karte](https://karte.io/) to your Patapata app. +It will automatically send events to Karte from Patapata's Analytics system. +It will also integrate with Patapata's User system and automatically send user information to Karte. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```yaml +dependencies: + patapata_karte_core: + git: + url: git://github.com/gree/patapata.git + path: packages/patapata_karte_core +``` + +2. Add settings to Android +Add a string resource to `android/app/src/main/res/values/strings.xml` with name `patapata_karte_app_key` and value your Karte app key. + +```xml +YOUR_APP_KEY +``` + +3. Add settings to iOS +Add a string resource to `ios/Runner/Info.plist` with name `patapata_karte_app_key` and value your Karte app key. + +```xml +patapata_karte_app_key +YOUR_APP_KEY +``` + +4. Import the package + +```dart +import 'package:patapata_karte_core/patapata_karte_core.dart'; +``` + +5. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + KarteCorePlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_karte_core/LICENSE) \ No newline at end of file diff --git a/packages/patapata_karte_core/analysis_options.yaml b/packages/patapata_karte_core/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_karte_core/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_karte_core/android/.gitignore b/packages/patapata_karte_core/android/.gitignore new file mode 100644 index 0000000..c6cbe56 --- /dev/null +++ b/packages/patapata_karte_core/android/.gitignore @@ -0,0 +1,8 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures diff --git a/packages/patapata_karte_core/android/build.gradle b/packages/patapata_karte_core/android/build.gradle new file mode 100644 index 0000000..6acaf05 --- /dev/null +++ b/packages/patapata_karte_core/android/build.gradle @@ -0,0 +1,54 @@ +group 'dev.patapata.patapata_karte_core' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 30 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 16 + } + + dependencies { + compileOnly findProject(":patapata_core") + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/packages/patapata_karte_core/android/gradle.properties b/packages/patapata_karte_core/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/packages/patapata_karte_core/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/packages/patapata_karte_core/android/gradle/wrapper/gradle-wrapper.properties b/packages/patapata_karte_core/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c9d085 --- /dev/null +++ b/packages/patapata_karte_core/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/packages/patapata_karte_core/android/settings.gradle b/packages/patapata_karte_core/android/settings.gradle new file mode 100644 index 0000000..727784b --- /dev/null +++ b/packages/patapata_karte_core/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'patapata_karte_core' diff --git a/packages/patapata_karte_core/android/src/main/AndroidManifest.xml b/packages/patapata_karte_core/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dd88d98 --- /dev/null +++ b/packages/patapata_karte_core/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/patapata_karte_core/android/src/main/kotlin/dev/patapata/patapata_karte_core/PatapataKarteCorePlugin.kt b/packages/patapata_karte_core/android/src/main/kotlin/dev/patapata/patapata_karte_core/PatapataKarteCorePlugin.kt new file mode 100644 index 0000000..6c63a32 --- /dev/null +++ b/packages/patapata_karte_core/android/src/main/kotlin/dev/patapata/patapata_karte_core/PatapataKarteCorePlugin.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package dev.patapata.patapata_karte_core + +import android.util.Log +import androidx.annotation.NonNull +import dev.patapata.patapata_core.PatapataPlugin +import dev.patapata.patapata_core.registerPatapataPlugin +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.karte.android.KarteApp + +/** PatapataKarteCorePlugin */ +class PatapataKarteCorePlugin: FlutterPlugin, PatapataPlugin { + + private var mIsSetup = false + private var mIsEnabled = false + private var mBinding: FlutterPlugin.FlutterPluginBinding? = null + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + mBinding = flutterPluginBinding + flutterPluginBinding.registerPatapataPlugin(this) + + if (mIsEnabled) { + setup() + } + } + + private fun setup() { + mBinding?.apply { + mIsSetup = true + val tKarteAppKey = applicationContext.resources.getIdentifier("patapata_karte_app_key", "string", applicationContext.packageName) + if (tKarteAppKey != 0) { + //KarteApp.setLogLevel(LogLevel.DEBUG) + KarteApp.setup(applicationContext, applicationContext.getString(tKarteAppKey)) + + if (!mIsEnabled) { + KarteApp.optOut() + } + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + KarteApp.optOut() + mBinding = null + } + + override fun patapataEnable() { + mIsEnabled = true + + if (!mIsSetup) { + setup() + } + } + + override fun patapataDisable() { + mIsEnabled = false + + if (mIsSetup) { + KarteApp.optOut() + } + } + + override val patapataName: String + get() = "KarteCorePlugin" +} diff --git a/packages/patapata_karte_core/dartdoc_options.yaml b/packages/patapata_karte_core/dartdoc_options.yaml new file mode 100644 index 0000000..1e3a99c --- /dev/null +++ b/packages/patapata_karte_core/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_karte_core \ No newline at end of file diff --git a/packages/patapata_karte_core/ios/.gitignore b/packages/patapata_karte_core/ios/.gitignore new file mode 100644 index 0000000..0c88507 --- /dev/null +++ b/packages/patapata_karte_core/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/packages/patapata_karte_core/ios/Assets/.gitkeep b/packages/patapata_karte_core/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/patapata_karte_core/ios/Classes/PatapataKarteCorePlugin.h b/packages/patapata_karte_core/ios/Classes/PatapataKarteCorePlugin.h new file mode 100644 index 0000000..68894d5 --- /dev/null +++ b/packages/patapata_karte_core/ios/Classes/PatapataKarteCorePlugin.h @@ -0,0 +1,11 @@ +/* + * Copyright (c) GREE, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@interface PatapataKarteCorePlugin : NSObject +@end diff --git a/packages/patapata_karte_core/ios/Classes/PatapataKarteCorePlugin.m b/packages/patapata_karte_core/ios/Classes/PatapataKarteCorePlugin.m new file mode 100644 index 0000000..0bc187a --- /dev/null +++ b/packages/patapata_karte_core/ios/Classes/PatapataKarteCorePlugin.m @@ -0,0 +1,20 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +#import "PatapataKarteCorePlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "patapata_karte_core-Swift.h" +#endif + +@implementation PatapataKarteCorePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftPatapataKarteCorePlugin registerWithRegistrar:registrar]; +} +@end diff --git a/packages/patapata_karte_core/ios/Classes/SwiftPatapataKarteCorePlugin.swift b/packages/patapata_karte_core/ios/Classes/SwiftPatapataKarteCorePlugin.swift new file mode 100644 index 0000000..25cbce3 --- /dev/null +++ b/packages/patapata_karte_core/ios/Classes/SwiftPatapataKarteCorePlugin.swift @@ -0,0 +1,22 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import Flutter +import UIKit +import KarteCore + +public class SwiftPatapataKarteCorePlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + if let tData = Bundle.main.infoDictionary, let tKey = tData["patapata_karte_app_key"] as? String { + // KarteApp.setLogLevel(.debug) + let tEnvironment = ProcessInfo().environment + // Karte fails to setup with a crash in a XCTest environment + if (tEnvironment["XCTestConfigurationFilePath"] == nil) { + KarteApp.setup(appKey: tKey) + KarteApp.optOut() + } + } + } +} diff --git a/packages/patapata_karte_core/ios/patapata_karte_core.podspec b/packages/patapata_karte_core/ios/patapata_karte_core.podspec new file mode 100644 index 0000000..545a89f --- /dev/null +++ b/packages/patapata_karte_core/ios/patapata_karte_core.podspec @@ -0,0 +1,31 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint patapata_karte_core.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'patapata_karte_core' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '8.0' + + s.dependency 'patapata_core' + s.dependency 'karte_core' + # To handle an sqlite conflict + s.dependency 'KarteUtilities/sqlite-standalone' + + # Karte declares this too, so we have to as well. + s.static_framework = true + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/packages/patapata_karte_core/lib/patapata_karte_core.dart b/packages/patapata_karte_core/lib/patapata_karte_core.dart new file mode 100644 index 0000000..20b6894 --- /dev/null +++ b/packages/patapata_karte_core/lib/patapata_karte_core.dart @@ -0,0 +1,98 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_karte_core; + +import 'dart:async'; + +import 'package:karte_core/karte_core.dart'; +import 'package:patapata_core/patapata_core.dart'; + +/// A plugin that provides Karte functionality in Patapata. +class KarteCorePlugin extends Plugin { + late final StreamSubscription _eventsSubscription; + + /// Initializes the [KarteCorePlugin]. + @override + FutureOr init(App app) async { + await super.init(app); + + KarteApp.optIn(); + _eventsSubscription = + app.analytics.eventsFor().listen(_onEvent); + app.user.addSynchronousChangeListener(_onUserChanged); + + return true; + } + + @override + FutureOr dispose() async { + app.user.removeSynchronousChangeListener(_onUserChanged); + + await super.dispose(); + + KarteApp.optOut(); + _eventsSubscription.cancel(); + } + + void _onEvent(AnalyticsEvent event) { + final tFlatData = event.flatData; + final tParameters = tFlatData != null + ? { + for (var i in tFlatData.entries) + i.key: Analytics.defaultMakeLoggableToNative(i.value), + } + : null; + + final tContextData = event.navigationInteractionContextData; + final Map tContextParameters = tContextData != null + ? { + for (var i in tContextData.entries) + i.key: Analytics.defaultMakeLoggableToNative(i.value), + } + : {}; + + if (event is AnalyticsRouteViewEvent) { + Tracker.view( + event.routeName ?? '', + event.routeName, + { + 'navigationInteractionContext': tContextParameters, + }..addAll(tParameters ?? {}), + ); + } else { + Tracker.track( + event.name, + { + 'navigationInteractionContext': tContextParameters, + }..addAll(tParameters ?? {}), + ); + } + } + + String? _lastId; + + FutureOr _onUserChanged(User user, UserChangeData changes) { + final tId = changes.getIdFor(); + final tProperties = changes.getPropertiesFor(); + + if (tId != _lastId) { + _lastId = tId; + + if (tId == null) { + KarteApp.renewVisitorId(); + } + + Tracker.identify({ + 'user_id': tId, + for (var i in tProperties.entries) i.key: i.value, + }); + } else { + Tracker.identify({ + for (var i in tProperties.entries) i.key: i.value, + }); + } + } +} diff --git a/packages/patapata_karte_core/melos_patapata_karte_core.iml b/packages/patapata_karte_core/melos_patapata_karte_core.iml new file mode 100644 index 0000000..9fc8ce7 --- /dev/null +++ b/packages/patapata_karte_core/melos_patapata_karte_core.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/patapata_karte_core/pubspec.yaml b/packages/patapata_karte_core/pubspec.yaml new file mode 100644 index 0000000..e49e101 --- /dev/null +++ b/packages/patapata_karte_core/pubspec.yaml @@ -0,0 +1,33 @@ +name: patapata_karte_core +description: This package is a plugin for Patapata that adds support for Karte to your Patapata app. +version: 1.0.0 +publish_to: none +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_karte_core + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + karte_core: ^1.2.1 + karte_in_app_messaging: ^1.2.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: + plugin: + platforms: + android: + package: dev.patapata.patapata_karte_core + pluginClass: PatapataKarteCorePlugin + ios: + pluginClass: PatapataKarteCorePlugin diff --git a/packages/patapata_karte_core/test/patapata_karte_core_test.dart b/packages/patapata_karte_core/test/patapata_karte_core_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_karte_core/test/patapata_karte_core_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_karte_variables/.gitignore b/packages/patapata_karte_variables/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_karte_variables/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_karte_variables/.metadata b/packages/patapata_karte_variables/.metadata new file mode 100644 index 0000000..4311ca2 --- /dev/null +++ b/packages/patapata_karte_variables/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: package diff --git a/packages/patapata_karte_variables/CHANGELOG.md b/packages/patapata_karte_variables/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_karte_variables/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_karte_variables/LICENSE b/packages/patapata_karte_variables/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_karte_variables/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_karte_variables/README.md b/packages/patapata_karte_variables/README.md new file mode 100644 index 0000000..e4fbbb3 --- /dev/null +++ b/packages/patapata_karte_variables/README.md @@ -0,0 +1,55 @@ +
    +

    Patapata - Karte Variables

    +

    + Add support for Karte Variables to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Karte Variables](https://pub.dev/packages/karte_variables) to your Patapata app. +It will automatically fetch variables from Karte and make them available in your app via Patapata's RemoteConfig system. + +This plugin requires [Patapata Karte Core](https://github.com/gree/patapata/packages/patapata_karte_core) to be installed and configured. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```yaml +dependencies: + patapata_karte_variables: + git: + url: git://github.com/gree/patapata.git + path: packages/patapata_karte_variables +``` + +2. Import the package + +```dart +import 'package:patapata_karte_variables/patapata_karte_variables.dart'; +``` + +3. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + KarteCorePlugin(), + KarteVariablesPlugin(), + ], + ) + .run(); +} +``` + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_karte_variables/LICENSE) \ No newline at end of file diff --git a/packages/patapata_karte_variables/analysis_options.yaml b/packages/patapata_karte_variables/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_karte_variables/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_karte_variables/dartdoc_options.yaml b/packages/patapata_karte_variables/dartdoc_options.yaml new file mode 100644 index 0000000..b2b045c --- /dev/null +++ b/packages/patapata_karte_variables/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_karte_variables \ No newline at end of file diff --git a/packages/patapata_karte_variables/lib/patapata_karte_variables.dart b/packages/patapata_karte_variables/lib/patapata_karte_variables.dart new file mode 100644 index 0000000..959ecec --- /dev/null +++ b/packages/patapata_karte_variables/lib/patapata_karte_variables.dart @@ -0,0 +1,216 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_karte_variables; + +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:karte_variables/karte_variables.dart'; +import 'package:patapata_karte_core/patapata_karte_core.dart'; + +final _logger = Logger('patapata.KarteVariablesPlugin'); + +/// A plugin that provides KarteVariables functionality. For information about KarteVariables, please refer to the [official documentation](https://pub.dev/packages/karte_variables). +/// To use this plugin, you need to add the [patapata_karte_core](https://pub.dev/packages/patapata_karte_core) package to your application. +class KarteVariablesPlugin extends Plugin { + @override + List get dependencies => [KarteCorePlugin]; + + @override + RemoteConfig createRemoteConfig() => _KarteVariablesPluginRemoteConfig(); +} + +class _KarteVariablesPluginRemoteConfig extends RemoteConfig { + Map _defaults = {}; + final Map _variables = {}; + bool _updating = false; + bool _fetching = false; + + Future _updateVariablesFromDefaults() async { + _updating = true; + + try { + _variables.clear(); + + for (var tKey in _defaults.keys) { + final tDefaultValue = _defaults[tKey]; + + switch (tDefaultValue?.runtimeType) { + case int: + await _updateVariable(tKey, tDefaultValue as int); + break; + case double: + await _updateVariable(tKey, tDefaultValue as double); + break; + case String: + await _updateVariable(tKey, tDefaultValue as String); + break; + case bool: + await _updateVariable(tKey, tDefaultValue as bool); + break; + default: + break; + } + } + + _updating = false; + + if (_defaults.isNotEmpty) { + onChange(); + } + } catch (e, stackTrace) { + _updating = false; + _logger.warning('Failed to update Variables', e, stackTrace); + } + } + + Future _updateVariable(String key, T defaultValue) async { + final tVariable = await Variables.get(key); + dynamic tValue; + + switch (T) { + case int: + tValue = await tVariable.getInteger(defaultValue as int); + break; + case double: + tValue = await tVariable.getDouble(defaultValue as double); + break; + case String: + tValue = await tVariable.getString(defaultValue as String); + break; + case bool: + tValue = await tVariable.getBoolean(defaultValue as bool); + break; + default: + break; + } + + _variables[key] = tValue; + + return tValue; + } + + @override + Future fetch({ + Duration expiration = const Duration(hours: 5), + bool force = false, + }) async { + if (_fetching) { + return; + } + + _fetching = true; + + try { + await Variables.fetch(); + // ignore: todo + // TODO: Expiration. Just make a timer. + } catch (e, stackTrace) { + _logger.warning('Failed to fetch Variables', e, stackTrace); + } + + if (_updating) { + return; + } + + // Karte Flutter version doesn't support getting variables + // synchronously. So we cheat and use the defaults as a hint + // that these are going to get getted. + // If something unexpected not in defaults comes along... + // we get it async and use onChange to tell people + // it updated while returning the default value before that. + // Not a great solution but yeah... + await _updateVariablesFromDefaults(); + + _fetching = false; + } + + @override + bool getBool(String key, {bool defaultValue = Config.defaultValueForBool}) { + if (_variables.containsKey(key)) { + final tVariable = _variables[key]; + + return tVariable is bool ? tVariable : defaultValue; + } else { + // We got an unexpected get. Update it. + _updateVariable(key, defaultValue).then((v) { + onChange(); + }); + + // Until that's done we have to return the default value. + return defaultValue; + } + } + + @override + double getDouble(String key, + {double defaultValue = Config.defaultValueForDouble}) { + if (_variables.containsKey(key)) { + final tVariable = _variables[key]; + + return tVariable is double ? tVariable : defaultValue; + } else { + // We got an unexpected get. Update it. + _updateVariable(key, defaultValue).then((v) { + onChange(); + }); + + // Until that's done we have to return the default value. + return defaultValue; + } + } + + @override + int getInt(String key, {int defaultValue = Config.defaultValueForInt}) { + if (_variables.containsKey(key)) { + final tVariable = _variables[key]; + + return tVariable is int ? tVariable : defaultValue; + } else { + // We got an unexpected get. Update it. + _updateVariable(key, defaultValue).then((v) { + onChange(); + }); + + // Until that's done we have to return the default value. + return defaultValue; + } + } + + @override + String getString(String key, + {String defaultValue = Config.defaultValueForString}) { + if (_variables.containsKey(key)) { + final tVariable = _variables[key]; + + return tVariable is String ? tVariable : defaultValue; + } else { + // We got an unexpected get. Update it. + _updateVariable(key, defaultValue).then((v) { + onChange(); + }); + + // Until that's done we have to return the default value. + return defaultValue; + } + } + + @override + bool hasKey(String key) { + // This is not 100% correct. + // This will return a false positive when + // a _defaults value that was saved in + // _variables exists (most of the time...) + // However Karte doesn't provide a way to know + // if a key exists or not so we do the best we can. + return _variables.containsKey(key); + } + + @override + Future setDefaults(Map defaults) async { + _defaults = Map.of(defaults); + await _updateVariablesFromDefaults(); + } +} diff --git a/packages/patapata_karte_variables/pubspec.yaml b/packages/patapata_karte_variables/pubspec.yaml new file mode 100644 index 0000000..57792d4 --- /dev/null +++ b/packages/patapata_karte_variables/pubspec.yaml @@ -0,0 +1,27 @@ +name: patapata_karte_variables +description: This package is a plugin for Patapata that adds support for Karte Variables to your Patapata app. +version: 1.0.0 +publish_to: none +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_karte_variables + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + patapata_karte_core: ^1.0.0 + + karte_variables: ^1.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: diff --git a/packages/patapata_karte_variables/test/patapata_karte_variables_test.dart b/packages/patapata_karte_variables/test/patapata_karte_variables_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_karte_variables/test/patapata_karte_variables_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/packages/patapata_riverpod/.gitignore b/packages/patapata_riverpod/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_riverpod/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_riverpod/.metadata b/packages/patapata_riverpod/.metadata new file mode 100644 index 0000000..6176c00 --- /dev/null +++ b/packages/patapata_riverpod/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d211f42860350d914a5ad8102f9ec32764dc6d06" + channel: "stable" + +project_type: package diff --git a/packages/patapata_riverpod/CHANGELOG.md b/packages/patapata_riverpod/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_riverpod/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_riverpod/LICENSE b/packages/patapata_riverpod/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_riverpod/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_riverpod/README.md b/packages/patapata_riverpod/README.md new file mode 100644 index 0000000..4654883 --- /dev/null +++ b/packages/patapata_riverpod/README.md @@ -0,0 +1,51 @@ +
    +

    Patapata - Riverpod

    +

    + Add support for Riverpod to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Riverpod](https://pub.dev/packages/riverpod) to your Patapata app. + +It will automatically inject your app's environment into your Riverpod providers. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_riverpod +``` + +2. Import the package + +```dart +import 'package:patapata_riverpod/patapata_riverpod.dart'; +``` + +3. Activate the plugin + +```dart +void main() { + App( + environment: const Environment(), + plugins: [ + RiverpodPlugin(), + ], + ) + .run(); +} +``` + +4. See the providers you can use by reading the [API documentation](https://pub.dev/documentation/patapata_riverpod/latest/patapata_riverpod/patapata_riverpod-library.html). + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_riverpod/LICENSE) \ No newline at end of file diff --git a/packages/patapata_riverpod/analysis_options.yaml b/packages/patapata_riverpod/analysis_options.yaml new file mode 100644 index 0000000..cade7bd --- /dev/null +++ b/packages/patapata_riverpod/analysis_options.yaml @@ -0,0 +1,11 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + plugins: + - custom_lint + exclude: + - example/** diff --git a/packages/patapata_riverpod/lib/patapata_riverpod.dart b/packages/patapata_riverpod/lib/patapata_riverpod.dart new file mode 100644 index 0000000..3a20ef5 --- /dev/null +++ b/packages/patapata_riverpod/lib/patapata_riverpod.dart @@ -0,0 +1,9 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_riverpod; + +export 'src/riverpod_plugin.dart'; +export 'src/providers.dart'; diff --git a/packages/patapata_riverpod/lib/src/providers.dart b/packages/patapata_riverpod/lib/src/providers.dart new file mode 100644 index 0000000..ef4e2d3 --- /dev/null +++ b/packages/patapata_riverpod/lib/src/providers.dart @@ -0,0 +1,198 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'providers.g.dart'; + +/// The [App] instance. +@riverpod +App app(AppRef ref) { + return getApp(); +} + +/// The current [User]. +/// Whenever [User] changes, this provider will be updated. +@riverpod +Raw user(UserRef ref) { + return ref.disposeAndListenChangeNotifier(ref.read(appProvider).user); +} + +/// Access to [RemoteConfig]. +/// Whenever [RemoteConfig] changes, this provider will be updated. +@riverpod +Raw remoteConfig(RemoteConfigRef ref) { + return ref.disposeAndListenChangeNotifier(ref.read(appProvider).remoteConfig); +} + +/// Gets a [RemoteConfig] value as a [String]. +/// Whenever this [key] changes, this provider will be updated. +@riverpod +String remoteConfigString(RemoteConfigStringRef ref, String key, + [String defaultValue = Config.defaultValueForString]) { + return ref.watch(remoteConfigProvider + .select((v) => v.getString(key, defaultValue: defaultValue))); +} + +/// Gets a [RemoteConfig] value as a [int]. +/// Whenever this [key] changes, this provider will be updated. +@riverpod +int remoteConfigInt(RemoteConfigIntRef ref, String key, + [int defaultValue = Config.defaultValueForInt]) { + return ref.watch(remoteConfigProvider + .select((v) => v.getInt(key, defaultValue: defaultValue))); +} + +/// Gets a [RemoteConfig] value as a [double]. +/// Whenever this [key] changes, this provider will be updated. +@riverpod +double remoteConfigDouble(RemoteConfigDoubleRef ref, String key, + [double defaultValue = Config.defaultValueForDouble]) { + return ref.watch(remoteConfigProvider + .select((v) => v.getDouble(key, defaultValue: defaultValue))); +} + +/// Gets a [RemoteConfig] value as a [bool]. +/// Whenever this [key] changes, this provider will be updated. +@riverpod +bool remoteConfigBool(RemoteConfigBoolRef ref, String key, + [bool defaultValue = Config.defaultValueForBool]) { + return ref.watch(remoteConfigProvider + .select((v) => v.getBool(key, defaultValue: defaultValue))); +} + +/// Access to [LocalConfig]. +/// Whenever [LocalConfig] changes, this provider will be updated. +@riverpod +Raw localConfig(LocalConfigRef ref) { + return ref.disposeAndListenChangeNotifier(ref.read(appProvider).localConfig); +} + +/// Gets a [LocalConfig] value as a [String]. +/// Whenever this [key] changes, this provider will be updated. +@riverpod +String localConfigString(LocalConfigStringRef ref, String key, + [String defaultValue = Config.defaultValueForString]) { + return ref.watch(localConfigProvider + .select((v) => v.getString(key, defaultValue: defaultValue))); +} + +/// Gets a [LocalConfig] value as a [int]. +/// Whenever this [key] changes, this provider will be updated. +@riverpod +int localConfigInt(LocalConfigIntRef ref, String key, + [int defaultValue = Config.defaultValueForInt]) { + return ref.watch(localConfigProvider + .select((v) => v.getInt(key, defaultValue: defaultValue))); +} + +/// Gets a [LocalConfig] value as a [double]. +/// Whenever this [key] changes, this provider will be updated. +@riverpod +double localConfigDouble(LocalConfigDoubleRef ref, String key, + [double defaultValue = Config.defaultValueForDouble]) { + return ref.watch(localConfigProvider + .select((v) => v.getDouble(key, defaultValue: defaultValue))); +} + +/// Gets a [LocalConfig] value as a [bool]. +/// Whenever this [key] changes, this provider will be updated. +@riverpod +bool localConfigBool(LocalConfigBoolRef ref, String key, + [bool defaultValue = Config.defaultValueForBool]) { + return ref.watch(localConfigProvider + .select((v) => v.getBool(key, defaultValue: defaultValue))); +} + +/// Access to [RemoteMessaging]. +/// Whenever [RemoteMessaging] changes, this provider will be updated. +@riverpod +Raw remoteMessaging(RemoteMessagingRef ref) { + return ref + .disposeAndListenChangeNotifier(ref.read(appProvider).remoteMessaging); +} + +/// Access to [RemoteMessaging.messages]. +/// Whenever a new [RemoteMessage] is receieved via [RemoteMessaging.messages], this provider will be updated. +/// The first execution of this will return the initial message from [RemoteMessaging.getInitialMessage]. +@riverpod +Stream remoteMessagingMessages( + RemoteMessagingMessagesRef ref) async* { + final tRemoteMessaging = ref.watch(remoteMessagingProvider); + + final tInitialMessage = await tRemoteMessaging.getInitialMessage(); + + if (tInitialMessage != null) { + yield tInitialMessage; + } + + yield* tRemoteMessaging.messages; +} + +/// Access to [RemoteMessaging.tokens]. +/// Whenever a new token is receieved via [RemoteMessaging.tokens], this provider will be updated. +/// The first execution of this will return the current token from [RemoteMessaging.getToken]. +@riverpod +Stream remoteMessagingTokens(RemoteMessagingTokensRef ref) async* { + final tRemoteMessaging = ref.watch(remoteMessagingProvider); + + yield await tRemoteMessaging.getToken(); + yield* tRemoteMessaging.tokens; +} + +/// Access to [Analytics]. +@riverpod +Analytics analytics(AnalyticsRef ref) { + return ref.read(appProvider).analytics; +} + +/// Access to the global [AnalyticsContext] from [Analytics.globalContext]. +@riverpod +AnalyticsContext globalAnalyticsContext(GlobalAnalyticsContextRef ref) { + return ref.read(analyticsProvider).globalContext; +} + +/// Access to a stream of [NetworkInformation]. +/// Whenever [NetworkInformation] changes, this provider will be updated. +@riverpod +NetworkInformation networkInformation(NetworkInformationRef ref) { + final tNetworkPlugin = ref.read(appProvider).getPlugin()!; + + final tSubscription = tNetworkPlugin.informationStream.listen((event) { + ref.invalidateSelf(); + }); + + ref.onDispose(tSubscription.cancel); + + return tNetworkPlugin.information; +} + +/// Access to [PackageInfo]. +@riverpod +PackageInfoPlugin packageInfo(PackageInfoRef ref) { + return ref.read(appProvider).package; +} + +/// Access to [DeviceInfo]. +@riverpod +DeviceInfoPlugin deviceInfo(DeviceInfoRef ref) { + return ref.read(appProvider).device; +} + +extension _Disposeable on Ref { + // We can move the previous logic to a Ref extension. + // This enables reusing the logic between providers + T disposeAndListenChangeNotifier(T notifier) { + onDispose(() { + notifier.removeListener(notifyListeners); + }); + notifier.addListener(notifyListeners); + // We return the notifier to ease the usage a bit + return notifier; + } +} diff --git a/packages/patapata_riverpod/lib/src/providers.g.dart b/packages/patapata_riverpod/lib/src/providers.g.dart new file mode 100644 index 0000000..924666e --- /dev/null +++ b/packages/patapata_riverpod/lib/src/providers.g.dart @@ -0,0 +1,1560 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$appHash() => r'b221fc2a0f1007056310f6ab8af4b3bdb50f2ecc'; + +/// The [App] instance. +/// +/// Copied from [app]. +@ProviderFor(app) +final appProvider = AutoDisposeProvider.internal( + app, + name: r'appProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$appHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AppRef = AutoDisposeProviderRef; +String _$userHash() => r'185492973048a3679bb21cb6e2bf4ce15012ec7e'; + +/// The current [User]. +/// Whenever [User] changes, this provider will be updated. +/// +/// Copied from [user]. +@ProviderFor(user) +final userProvider = AutoDisposeProvider>.internal( + user, + name: r'userProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$userHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef UserRef = AutoDisposeProviderRef>; +String _$remoteConfigHash() => r'8e75aba0bc4b5c62efcad5a0d20aba8538c17cd3'; + +/// Access to [RemoteConfig]. +/// Whenever [RemoteConfig] changes, this provider will be updated. +/// +/// Copied from [remoteConfig]. +@ProviderFor(remoteConfig) +final remoteConfigProvider = AutoDisposeProvider>.internal( + remoteConfig, + name: r'remoteConfigProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$remoteConfigHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef RemoteConfigRef = AutoDisposeProviderRef>; +String _$remoteConfigStringHash() => + r'd3defab5f7870a383673f72bbe259deb968ff000'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// Gets a [RemoteConfig] value as a [String]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigString]. +@ProviderFor(remoteConfigString) +const remoteConfigStringProvider = RemoteConfigStringFamily(); + +/// Gets a [RemoteConfig] value as a [String]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigString]. +class RemoteConfigStringFamily extends Family { + /// Gets a [RemoteConfig] value as a [String]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigString]. + const RemoteConfigStringFamily(); + + /// Gets a [RemoteConfig] value as a [String]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigString]. + RemoteConfigStringProvider call( + String key, [ + String defaultValue = Config.defaultValueForString, + ]) { + return RemoteConfigStringProvider( + key, + defaultValue, + ); + } + + @override + RemoteConfigStringProvider getProviderOverride( + covariant RemoteConfigStringProvider provider, + ) { + return call( + provider.key, + provider.defaultValue, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'remoteConfigStringProvider'; +} + +/// Gets a [RemoteConfig] value as a [String]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigString]. +class RemoteConfigStringProvider extends AutoDisposeProvider { + /// Gets a [RemoteConfig] value as a [String]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigString]. + RemoteConfigStringProvider( + String key, [ + String defaultValue = Config.defaultValueForString, + ]) : this._internal( + (ref) => remoteConfigString( + ref as RemoteConfigStringRef, + key, + defaultValue, + ), + from: remoteConfigStringProvider, + name: r'remoteConfigStringProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$remoteConfigStringHash, + dependencies: RemoteConfigStringFamily._dependencies, + allTransitiveDependencies: + RemoteConfigStringFamily._allTransitiveDependencies, + key: key, + defaultValue: defaultValue, + ); + + RemoteConfigStringProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.key, + required this.defaultValue, + }) : super.internal(); + + final String key; + final String defaultValue; + + @override + Override overrideWith( + String Function(RemoteConfigStringRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: RemoteConfigStringProvider._internal( + (ref) => create(ref as RemoteConfigStringRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + key: key, + defaultValue: defaultValue, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _RemoteConfigStringProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is RemoteConfigStringProvider && + other.key == key && + other.defaultValue == defaultValue; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, key.hashCode); + hash = _SystemHash.combine(hash, defaultValue.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin RemoteConfigStringRef on AutoDisposeProviderRef { + /// The parameter `key` of this provider. + String get key; + + /// The parameter `defaultValue` of this provider. + String get defaultValue; +} + +class _RemoteConfigStringProviderElement + extends AutoDisposeProviderElement with RemoteConfigStringRef { + _RemoteConfigStringProviderElement(super.provider); + + @override + String get key => (origin as RemoteConfigStringProvider).key; + @override + String get defaultValue => + (origin as RemoteConfigStringProvider).defaultValue; +} + +String _$remoteConfigIntHash() => r'102555b16644b3768133e933fbb7e6b4e84efe61'; + +/// Gets a [RemoteConfig] value as a [int]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigInt]. +@ProviderFor(remoteConfigInt) +const remoteConfigIntProvider = RemoteConfigIntFamily(); + +/// Gets a [RemoteConfig] value as a [int]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigInt]. +class RemoteConfigIntFamily extends Family { + /// Gets a [RemoteConfig] value as a [int]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigInt]. + const RemoteConfigIntFamily(); + + /// Gets a [RemoteConfig] value as a [int]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigInt]. + RemoteConfigIntProvider call( + String key, [ + int defaultValue = Config.defaultValueForInt, + ]) { + return RemoteConfigIntProvider( + key, + defaultValue, + ); + } + + @override + RemoteConfigIntProvider getProviderOverride( + covariant RemoteConfigIntProvider provider, + ) { + return call( + provider.key, + provider.defaultValue, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'remoteConfigIntProvider'; +} + +/// Gets a [RemoteConfig] value as a [int]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigInt]. +class RemoteConfigIntProvider extends AutoDisposeProvider { + /// Gets a [RemoteConfig] value as a [int]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigInt]. + RemoteConfigIntProvider( + String key, [ + int defaultValue = Config.defaultValueForInt, + ]) : this._internal( + (ref) => remoteConfigInt( + ref as RemoteConfigIntRef, + key, + defaultValue, + ), + from: remoteConfigIntProvider, + name: r'remoteConfigIntProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$remoteConfigIntHash, + dependencies: RemoteConfigIntFamily._dependencies, + allTransitiveDependencies: + RemoteConfigIntFamily._allTransitiveDependencies, + key: key, + defaultValue: defaultValue, + ); + + RemoteConfigIntProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.key, + required this.defaultValue, + }) : super.internal(); + + final String key; + final int defaultValue; + + @override + Override overrideWith( + int Function(RemoteConfigIntRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: RemoteConfigIntProvider._internal( + (ref) => create(ref as RemoteConfigIntRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + key: key, + defaultValue: defaultValue, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _RemoteConfigIntProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is RemoteConfigIntProvider && + other.key == key && + other.defaultValue == defaultValue; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, key.hashCode); + hash = _SystemHash.combine(hash, defaultValue.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin RemoteConfigIntRef on AutoDisposeProviderRef { + /// The parameter `key` of this provider. + String get key; + + /// The parameter `defaultValue` of this provider. + int get defaultValue; +} + +class _RemoteConfigIntProviderElement extends AutoDisposeProviderElement + with RemoteConfigIntRef { + _RemoteConfigIntProviderElement(super.provider); + + @override + String get key => (origin as RemoteConfigIntProvider).key; + @override + int get defaultValue => (origin as RemoteConfigIntProvider).defaultValue; +} + +String _$remoteConfigDoubleHash() => + r'f7cf93febfff6eab6e48c05bb7aa177e31aaf84b'; + +/// Gets a [RemoteConfig] value as a [double]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigDouble]. +@ProviderFor(remoteConfigDouble) +const remoteConfigDoubleProvider = RemoteConfigDoubleFamily(); + +/// Gets a [RemoteConfig] value as a [double]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigDouble]. +class RemoteConfigDoubleFamily extends Family { + /// Gets a [RemoteConfig] value as a [double]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigDouble]. + const RemoteConfigDoubleFamily(); + + /// Gets a [RemoteConfig] value as a [double]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigDouble]. + RemoteConfigDoubleProvider call( + String key, [ + double defaultValue = Config.defaultValueForDouble, + ]) { + return RemoteConfigDoubleProvider( + key, + defaultValue, + ); + } + + @override + RemoteConfigDoubleProvider getProviderOverride( + covariant RemoteConfigDoubleProvider provider, + ) { + return call( + provider.key, + provider.defaultValue, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'remoteConfigDoubleProvider'; +} + +/// Gets a [RemoteConfig] value as a [double]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigDouble]. +class RemoteConfigDoubleProvider extends AutoDisposeProvider { + /// Gets a [RemoteConfig] value as a [double]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigDouble]. + RemoteConfigDoubleProvider( + String key, [ + double defaultValue = Config.defaultValueForDouble, + ]) : this._internal( + (ref) => remoteConfigDouble( + ref as RemoteConfigDoubleRef, + key, + defaultValue, + ), + from: remoteConfigDoubleProvider, + name: r'remoteConfigDoubleProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$remoteConfigDoubleHash, + dependencies: RemoteConfigDoubleFamily._dependencies, + allTransitiveDependencies: + RemoteConfigDoubleFamily._allTransitiveDependencies, + key: key, + defaultValue: defaultValue, + ); + + RemoteConfigDoubleProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.key, + required this.defaultValue, + }) : super.internal(); + + final String key; + final double defaultValue; + + @override + Override overrideWith( + double Function(RemoteConfigDoubleRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: RemoteConfigDoubleProvider._internal( + (ref) => create(ref as RemoteConfigDoubleRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + key: key, + defaultValue: defaultValue, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _RemoteConfigDoubleProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is RemoteConfigDoubleProvider && + other.key == key && + other.defaultValue == defaultValue; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, key.hashCode); + hash = _SystemHash.combine(hash, defaultValue.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin RemoteConfigDoubleRef on AutoDisposeProviderRef { + /// The parameter `key` of this provider. + String get key; + + /// The parameter `defaultValue` of this provider. + double get defaultValue; +} + +class _RemoteConfigDoubleProviderElement + extends AutoDisposeProviderElement with RemoteConfigDoubleRef { + _RemoteConfigDoubleProviderElement(super.provider); + + @override + String get key => (origin as RemoteConfigDoubleProvider).key; + @override + double get defaultValue => + (origin as RemoteConfigDoubleProvider).defaultValue; +} + +String _$remoteConfigBoolHash() => r'13a9e5417b965b88f843d5fa69b7cb03cb9fea95'; + +/// Gets a [RemoteConfig] value as a [bool]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigBool]. +@ProviderFor(remoteConfigBool) +const remoteConfigBoolProvider = RemoteConfigBoolFamily(); + +/// Gets a [RemoteConfig] value as a [bool]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigBool]. +class RemoteConfigBoolFamily extends Family { + /// Gets a [RemoteConfig] value as a [bool]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigBool]. + const RemoteConfigBoolFamily(); + + /// Gets a [RemoteConfig] value as a [bool]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigBool]. + RemoteConfigBoolProvider call( + String key, [ + bool defaultValue = Config.defaultValueForBool, + ]) { + return RemoteConfigBoolProvider( + key, + defaultValue, + ); + } + + @override + RemoteConfigBoolProvider getProviderOverride( + covariant RemoteConfigBoolProvider provider, + ) { + return call( + provider.key, + provider.defaultValue, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'remoteConfigBoolProvider'; +} + +/// Gets a [RemoteConfig] value as a [bool]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [remoteConfigBool]. +class RemoteConfigBoolProvider extends AutoDisposeProvider { + /// Gets a [RemoteConfig] value as a [bool]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [remoteConfigBool]. + RemoteConfigBoolProvider( + String key, [ + bool defaultValue = Config.defaultValueForBool, + ]) : this._internal( + (ref) => remoteConfigBool( + ref as RemoteConfigBoolRef, + key, + defaultValue, + ), + from: remoteConfigBoolProvider, + name: r'remoteConfigBoolProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$remoteConfigBoolHash, + dependencies: RemoteConfigBoolFamily._dependencies, + allTransitiveDependencies: + RemoteConfigBoolFamily._allTransitiveDependencies, + key: key, + defaultValue: defaultValue, + ); + + RemoteConfigBoolProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.key, + required this.defaultValue, + }) : super.internal(); + + final String key; + final bool defaultValue; + + @override + Override overrideWith( + bool Function(RemoteConfigBoolRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: RemoteConfigBoolProvider._internal( + (ref) => create(ref as RemoteConfigBoolRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + key: key, + defaultValue: defaultValue, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _RemoteConfigBoolProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is RemoteConfigBoolProvider && + other.key == key && + other.defaultValue == defaultValue; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, key.hashCode); + hash = _SystemHash.combine(hash, defaultValue.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin RemoteConfigBoolRef on AutoDisposeProviderRef { + /// The parameter `key` of this provider. + String get key; + + /// The parameter `defaultValue` of this provider. + bool get defaultValue; +} + +class _RemoteConfigBoolProviderElement extends AutoDisposeProviderElement + with RemoteConfigBoolRef { + _RemoteConfigBoolProviderElement(super.provider); + + @override + String get key => (origin as RemoteConfigBoolProvider).key; + @override + bool get defaultValue => (origin as RemoteConfigBoolProvider).defaultValue; +} + +String _$localConfigHash() => r'2d0900f1e1f41f25456b738fcc2a18d4fd7cca59'; + +/// Access to [LocalConfig]. +/// Whenever [LocalConfig] changes, this provider will be updated. +/// +/// Copied from [localConfig]. +@ProviderFor(localConfig) +final localConfigProvider = AutoDisposeProvider>.internal( + localConfig, + name: r'localConfigProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$localConfigHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef LocalConfigRef = AutoDisposeProviderRef>; +String _$localConfigStringHash() => r'5d87800e6aa2139e9129dba2d78145a941927fc5'; + +/// Gets a [LocalConfig] value as a [String]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigString]. +@ProviderFor(localConfigString) +const localConfigStringProvider = LocalConfigStringFamily(); + +/// Gets a [LocalConfig] value as a [String]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigString]. +class LocalConfigStringFamily extends Family { + /// Gets a [LocalConfig] value as a [String]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigString]. + const LocalConfigStringFamily(); + + /// Gets a [LocalConfig] value as a [String]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigString]. + LocalConfigStringProvider call( + String key, [ + String defaultValue = Config.defaultValueForString, + ]) { + return LocalConfigStringProvider( + key, + defaultValue, + ); + } + + @override + LocalConfigStringProvider getProviderOverride( + covariant LocalConfigStringProvider provider, + ) { + return call( + provider.key, + provider.defaultValue, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'localConfigStringProvider'; +} + +/// Gets a [LocalConfig] value as a [String]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigString]. +class LocalConfigStringProvider extends AutoDisposeProvider { + /// Gets a [LocalConfig] value as a [String]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigString]. + LocalConfigStringProvider( + String key, [ + String defaultValue = Config.defaultValueForString, + ]) : this._internal( + (ref) => localConfigString( + ref as LocalConfigStringRef, + key, + defaultValue, + ), + from: localConfigStringProvider, + name: r'localConfigStringProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$localConfigStringHash, + dependencies: LocalConfigStringFamily._dependencies, + allTransitiveDependencies: + LocalConfigStringFamily._allTransitiveDependencies, + key: key, + defaultValue: defaultValue, + ); + + LocalConfigStringProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.key, + required this.defaultValue, + }) : super.internal(); + + final String key; + final String defaultValue; + + @override + Override overrideWith( + String Function(LocalConfigStringRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LocalConfigStringProvider._internal( + (ref) => create(ref as LocalConfigStringRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + key: key, + defaultValue: defaultValue, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _LocalConfigStringProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LocalConfigStringProvider && + other.key == key && + other.defaultValue == defaultValue; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, key.hashCode); + hash = _SystemHash.combine(hash, defaultValue.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin LocalConfigStringRef on AutoDisposeProviderRef { + /// The parameter `key` of this provider. + String get key; + + /// The parameter `defaultValue` of this provider. + String get defaultValue; +} + +class _LocalConfigStringProviderElement + extends AutoDisposeProviderElement with LocalConfigStringRef { + _LocalConfigStringProviderElement(super.provider); + + @override + String get key => (origin as LocalConfigStringProvider).key; + @override + String get defaultValue => (origin as LocalConfigStringProvider).defaultValue; +} + +String _$localConfigIntHash() => r'4050a4918e2d6b5b05931444019231f0dd3def14'; + +/// Gets a [LocalConfig] value as a [int]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigInt]. +@ProviderFor(localConfigInt) +const localConfigIntProvider = LocalConfigIntFamily(); + +/// Gets a [LocalConfig] value as a [int]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigInt]. +class LocalConfigIntFamily extends Family { + /// Gets a [LocalConfig] value as a [int]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigInt]. + const LocalConfigIntFamily(); + + /// Gets a [LocalConfig] value as a [int]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigInt]. + LocalConfigIntProvider call( + String key, [ + int defaultValue = Config.defaultValueForInt, + ]) { + return LocalConfigIntProvider( + key, + defaultValue, + ); + } + + @override + LocalConfigIntProvider getProviderOverride( + covariant LocalConfigIntProvider provider, + ) { + return call( + provider.key, + provider.defaultValue, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'localConfigIntProvider'; +} + +/// Gets a [LocalConfig] value as a [int]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigInt]. +class LocalConfigIntProvider extends AutoDisposeProvider { + /// Gets a [LocalConfig] value as a [int]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigInt]. + LocalConfigIntProvider( + String key, [ + int defaultValue = Config.defaultValueForInt, + ]) : this._internal( + (ref) => localConfigInt( + ref as LocalConfigIntRef, + key, + defaultValue, + ), + from: localConfigIntProvider, + name: r'localConfigIntProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$localConfigIntHash, + dependencies: LocalConfigIntFamily._dependencies, + allTransitiveDependencies: + LocalConfigIntFamily._allTransitiveDependencies, + key: key, + defaultValue: defaultValue, + ); + + LocalConfigIntProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.key, + required this.defaultValue, + }) : super.internal(); + + final String key; + final int defaultValue; + + @override + Override overrideWith( + int Function(LocalConfigIntRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LocalConfigIntProvider._internal( + (ref) => create(ref as LocalConfigIntRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + key: key, + defaultValue: defaultValue, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _LocalConfigIntProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LocalConfigIntProvider && + other.key == key && + other.defaultValue == defaultValue; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, key.hashCode); + hash = _SystemHash.combine(hash, defaultValue.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin LocalConfigIntRef on AutoDisposeProviderRef { + /// The parameter `key` of this provider. + String get key; + + /// The parameter `defaultValue` of this provider. + int get defaultValue; +} + +class _LocalConfigIntProviderElement extends AutoDisposeProviderElement + with LocalConfigIntRef { + _LocalConfigIntProviderElement(super.provider); + + @override + String get key => (origin as LocalConfigIntProvider).key; + @override + int get defaultValue => (origin as LocalConfigIntProvider).defaultValue; +} + +String _$localConfigDoubleHash() => r'177c15a70d201d5ec197ec3569a654193b6297fb'; + +/// Gets a [LocalConfig] value as a [double]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigDouble]. +@ProviderFor(localConfigDouble) +const localConfigDoubleProvider = LocalConfigDoubleFamily(); + +/// Gets a [LocalConfig] value as a [double]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigDouble]. +class LocalConfigDoubleFamily extends Family { + /// Gets a [LocalConfig] value as a [double]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigDouble]. + const LocalConfigDoubleFamily(); + + /// Gets a [LocalConfig] value as a [double]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigDouble]. + LocalConfigDoubleProvider call( + String key, [ + double defaultValue = Config.defaultValueForDouble, + ]) { + return LocalConfigDoubleProvider( + key, + defaultValue, + ); + } + + @override + LocalConfigDoubleProvider getProviderOverride( + covariant LocalConfigDoubleProvider provider, + ) { + return call( + provider.key, + provider.defaultValue, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'localConfigDoubleProvider'; +} + +/// Gets a [LocalConfig] value as a [double]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigDouble]. +class LocalConfigDoubleProvider extends AutoDisposeProvider { + /// Gets a [LocalConfig] value as a [double]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigDouble]. + LocalConfigDoubleProvider( + String key, [ + double defaultValue = Config.defaultValueForDouble, + ]) : this._internal( + (ref) => localConfigDouble( + ref as LocalConfigDoubleRef, + key, + defaultValue, + ), + from: localConfigDoubleProvider, + name: r'localConfigDoubleProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$localConfigDoubleHash, + dependencies: LocalConfigDoubleFamily._dependencies, + allTransitiveDependencies: + LocalConfigDoubleFamily._allTransitiveDependencies, + key: key, + defaultValue: defaultValue, + ); + + LocalConfigDoubleProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.key, + required this.defaultValue, + }) : super.internal(); + + final String key; + final double defaultValue; + + @override + Override overrideWith( + double Function(LocalConfigDoubleRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LocalConfigDoubleProvider._internal( + (ref) => create(ref as LocalConfigDoubleRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + key: key, + defaultValue: defaultValue, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _LocalConfigDoubleProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LocalConfigDoubleProvider && + other.key == key && + other.defaultValue == defaultValue; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, key.hashCode); + hash = _SystemHash.combine(hash, defaultValue.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin LocalConfigDoubleRef on AutoDisposeProviderRef { + /// The parameter `key` of this provider. + String get key; + + /// The parameter `defaultValue` of this provider. + double get defaultValue; +} + +class _LocalConfigDoubleProviderElement + extends AutoDisposeProviderElement with LocalConfigDoubleRef { + _LocalConfigDoubleProviderElement(super.provider); + + @override + String get key => (origin as LocalConfigDoubleProvider).key; + @override + double get defaultValue => (origin as LocalConfigDoubleProvider).defaultValue; +} + +String _$localConfigBoolHash() => r'45ff874a484e20c2ae29d4113d77914e6cf46696'; + +/// Gets a [LocalConfig] value as a [bool]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigBool]. +@ProviderFor(localConfigBool) +const localConfigBoolProvider = LocalConfigBoolFamily(); + +/// Gets a [LocalConfig] value as a [bool]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigBool]. +class LocalConfigBoolFamily extends Family { + /// Gets a [LocalConfig] value as a [bool]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigBool]. + const LocalConfigBoolFamily(); + + /// Gets a [LocalConfig] value as a [bool]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigBool]. + LocalConfigBoolProvider call( + String key, [ + bool defaultValue = Config.defaultValueForBool, + ]) { + return LocalConfigBoolProvider( + key, + defaultValue, + ); + } + + @override + LocalConfigBoolProvider getProviderOverride( + covariant LocalConfigBoolProvider provider, + ) { + return call( + provider.key, + provider.defaultValue, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'localConfigBoolProvider'; +} + +/// Gets a [LocalConfig] value as a [bool]. +/// Whenever this [key] changes, this provider will be updated. +/// +/// Copied from [localConfigBool]. +class LocalConfigBoolProvider extends AutoDisposeProvider { + /// Gets a [LocalConfig] value as a [bool]. + /// Whenever this [key] changes, this provider will be updated. + /// + /// Copied from [localConfigBool]. + LocalConfigBoolProvider( + String key, [ + bool defaultValue = Config.defaultValueForBool, + ]) : this._internal( + (ref) => localConfigBool( + ref as LocalConfigBoolRef, + key, + defaultValue, + ), + from: localConfigBoolProvider, + name: r'localConfigBoolProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$localConfigBoolHash, + dependencies: LocalConfigBoolFamily._dependencies, + allTransitiveDependencies: + LocalConfigBoolFamily._allTransitiveDependencies, + key: key, + defaultValue: defaultValue, + ); + + LocalConfigBoolProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.key, + required this.defaultValue, + }) : super.internal(); + + final String key; + final bool defaultValue; + + @override + Override overrideWith( + bool Function(LocalConfigBoolRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: LocalConfigBoolProvider._internal( + (ref) => create(ref as LocalConfigBoolRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + key: key, + defaultValue: defaultValue, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _LocalConfigBoolProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is LocalConfigBoolProvider && + other.key == key && + other.defaultValue == defaultValue; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, key.hashCode); + hash = _SystemHash.combine(hash, defaultValue.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin LocalConfigBoolRef on AutoDisposeProviderRef { + /// The parameter `key` of this provider. + String get key; + + /// The parameter `defaultValue` of this provider. + bool get defaultValue; +} + +class _LocalConfigBoolProviderElement extends AutoDisposeProviderElement + with LocalConfigBoolRef { + _LocalConfigBoolProviderElement(super.provider); + + @override + String get key => (origin as LocalConfigBoolProvider).key; + @override + bool get defaultValue => (origin as LocalConfigBoolProvider).defaultValue; +} + +String _$remoteMessagingHash() => r'3202165a9ba8b104810ce6db9ab4a689b08397f3'; + +/// Access to [RemoteMessaging]. +/// Whenever [RemoteMessaging] changes, this provider will be updated. +/// +/// Copied from [remoteMessaging]. +@ProviderFor(remoteMessaging) +final remoteMessagingProvider = + AutoDisposeProvider>.internal( + remoteMessaging, + name: r'remoteMessagingProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$remoteMessagingHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef RemoteMessagingRef = AutoDisposeProviderRef>; +String _$remoteMessagingMessagesHash() => + r'cceead0236a21e44607e4fdd1fb2e064ec52f6ae'; + +/// Access to [RemoteMessaging.messages]. +/// Whenever a new [RemoteMessage] is receieved via [RemoteMessaging.messages], this provider will be updated. +/// The first execution of this will return the initial message from [RemoteMessaging.getInitialMessage]. +/// +/// Copied from [remoteMessagingMessages]. +@ProviderFor(remoteMessagingMessages) +final remoteMessagingMessagesProvider = + AutoDisposeStreamProvider.internal( + remoteMessagingMessages, + name: r'remoteMessagingMessagesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$remoteMessagingMessagesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef RemoteMessagingMessagesRef + = AutoDisposeStreamProviderRef; +String _$remoteMessagingTokensHash() => + r'b336b909e5704473fc7b36dfa176063c7a9c66c2'; + +/// Access to [RemoteMessaging.tokens]. +/// Whenever a new token is receieved via [RemoteMessaging.tokens], this provider will be updated. +/// The first execution of this will return the current token from [RemoteMessaging.getToken]. +/// +/// Copied from [remoteMessagingTokens]. +@ProviderFor(remoteMessagingTokens) +final remoteMessagingTokensProvider = + AutoDisposeStreamProvider.internal( + remoteMessagingTokens, + name: r'remoteMessagingTokensProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$remoteMessagingTokensHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef RemoteMessagingTokensRef = AutoDisposeStreamProviderRef; +String _$analyticsHash() => r'1fd69a6ec7906d89d0ebaf111bed072b736dc3f3'; + +/// Access to [Analytics]. +/// +/// Copied from [analytics]. +@ProviderFor(analytics) +final analyticsProvider = AutoDisposeProvider.internal( + analytics, + name: r'analyticsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$analyticsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AnalyticsRef = AutoDisposeProviderRef; +String _$globalAnalyticsContextHash() => + r'0f4421d75f4445217509cff50843e89797133fce'; + +/// Access to the global [AnalyticsContext] from [Analytics.globalContext]. +/// +/// Copied from [globalAnalyticsContext]. +@ProviderFor(globalAnalyticsContext) +final globalAnalyticsContextProvider = + AutoDisposeProvider.internal( + globalAnalyticsContext, + name: r'globalAnalyticsContextProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$globalAnalyticsContextHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef GlobalAnalyticsContextRef = AutoDisposeProviderRef; +String _$networkInformationHash() => + r'13d7cae849d6733c528c329d79f79e1a43d5f53c'; + +/// Access to a stream of [NetworkInformation]. +/// Whenever [NetworkInformation] changes, this provider will be updated. +/// +/// Copied from [networkInformation]. +@ProviderFor(networkInformation) +final networkInformationProvider = + AutoDisposeProvider.internal( + networkInformation, + name: r'networkInformationProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$networkInformationHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef NetworkInformationRef = AutoDisposeProviderRef; +String _$packageInfoHash() => r'bb4b207e4e72b204cb2ce57645ec41ad09115b53'; + +/// Access to [PackageInfo]. +/// +/// Copied from [packageInfo]. +@ProviderFor(packageInfo) +final packageInfoProvider = AutoDisposeProvider.internal( + packageInfo, + name: r'packageInfoProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$packageInfoHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef PackageInfoRef = AutoDisposeProviderRef; +String _$deviceInfoHash() => r'737aa403f7f02cb999e488554c1110ae3b2f03d2'; + +/// Access to [DeviceInfo]. +/// +/// Copied from [deviceInfo]. +@ProviderFor(deviceInfo) +final deviceInfoProvider = AutoDisposeProvider.internal( + deviceInfo, + name: r'deviceInfoProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$deviceInfoHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef DeviceInfoRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/packages/patapata_riverpod/lib/src/riverpod_plugin.dart b/packages/patapata_riverpod/lib/src/riverpod_plugin.dart new file mode 100644 index 0000000..011e52f --- /dev/null +++ b/packages/patapata_riverpod/lib/src/riverpod_plugin.dart @@ -0,0 +1,15 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:patapata_core/patapata_core.dart'; + +class RiverpodPlugin extends Plugin { + @override + Widget createAppWidgetWrapper(Widget child) { + return ProviderScope(child: child); + } +} diff --git a/packages/patapata_riverpod/pubspec.yaml b/packages/patapata_riverpod/pubspec.yaml new file mode 100644 index 0000000..975481e --- /dev/null +++ b/packages/patapata_riverpod/pubspec.yaml @@ -0,0 +1,28 @@ +name: patapata_riverpod +description: This package is a plugin for Patapata that adds support for Riverpod to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_riverpod + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + flutter_riverpod: ^2.4.6 + patapata_core: ^1.0.0 + riverpod_annotation: ^2.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + riverpod_generator: ^2.3.6 + build_runner: ^2.4.6 + custom_lint: ^0.5.6 + riverpod_lint: ^2.3.4 + +flutter: diff --git a/packages/patapata_riverpod/test/patapata_riverpod_test.dart b/packages/patapata_riverpod/test/patapata_riverpod_test.dart new file mode 100644 index 0000000..b099faa --- /dev/null +++ b/packages/patapata_riverpod/test/patapata_riverpod_test.dart @@ -0,0 +1,659 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_widgets.dart'; +import 'package:patapata_riverpod/patapata_riverpod.dart'; + +class Environment {} + +App createApp([void Function(WidgetRef ref)? callback, Plugin? plugin]) { + return App( + environment: Environment(), + createAppWidget: (context, app) => StandardMaterialApp( + onGenerateTitle: (context) => l(context, 'title'), + pages: [ + StandardPageFactory( + create: (data) => TesterPage(), + pageDataWhenNull: () => TesterPageData(callback), + ), + ], + ), + plugins: [ + RiverpodPlugin(), + if (plugin != null) plugin, + ], + ); +} + +Future doRefTest( + WidgetTester tester, { + void Function(WidgetRef ref)? buildCallback, + FutureOr Function(WidgetTester tester, App app)? testCallback, + Plugin? plugin, +}) async { + final tApp = createApp(buildCallback, plugin); + + await tApp.run(); + + await tApp.runProcess(() async { + // Always pumpAndSettle to let Patapata finish initializing. + await tester.pumpAndSettle(); + + if (testCallback != null) { + await testCallback(tester, tApp); + } + }); + + tApp.dispose(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + testSetMockMethodCallHandler = TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger.setMockMethodCallHandler; + testSetMockStreamHandler = (channel, handler) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + channel, + _MockStreamHandler(handler), + ); + }; + + testWidgets('App can be accessed', (tester) async { + await doRefTest( + tester, + buildCallback: (ref) { + expect(ref.read(appProvider), getApp()); + }, + ); + }); + + testWidgets('User can be accessed', (tester) async { + await doRefTest( + tester, + buildCallback: (ref) { + expect(ref.read(userProvider), getApp().user); + }, + ); + }); + + testWidgets('User detects changes', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + String? tId; + + await doRefTest( + tester, + buildCallback: (ref) { + expect(ref.watch(userProvider).id, tId); + tShouldCall(); + }, + testCallback: (tester, app) async { + tId = 'tt2'; + await app.user.changeId('tt2'); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('RemoteConfig can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + String tValue = 'value1'; + final tConfig = MockRemoteConfig({ + 'string': tValue, + 'int': 1, + 'double': 1.0, + 'bool': true, + }); + + await doRefTest( + tester, + plugin: Plugin.inline( + createRemoteConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(remoteConfigProvider); + expect(tRef, getApp().remoteConfig); + expect(tRef.getString('string'), tConfig.getString('string')); + expect(tRef.getString('string'), tValue); + tShouldCall(); + }, + testCallback: (tester, app) async { + tValue = 'value2'; + await tConfig.setString('string', tValue); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('remoteConfigString can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockRemoteConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createRemoteConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(remoteConfigStringProvider('string')); + expect(tRef, tMap['string']); + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setString('string', tMap['string'] as String); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('remoteConfigInt can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockRemoteConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createRemoteConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(remoteConfigIntProvider('int')); + expect(tRef, tMap['int']); + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setInt('int', tMap['int'] as int); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('remoteConfigDouble can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockRemoteConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createRemoteConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(remoteConfigDoubleProvider('double')); + expect(tRef, tMap['double']); + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setDouble('double', tMap['double'] as double); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('remoteConfigBool can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockRemoteConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createRemoteConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(remoteConfigBoolProvider('bool')); + expect(tRef, tMap['bool']); + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setBool('bool', tMap['bool'] as bool); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('LocalConfig can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockLocalConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createLocalConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(localConfigProvider); + expect(tRef, getApp().localConfig); + + expect(tRef.getString('string'), tConfig.getString('string')); + expect(tRef.getString('string'), tMap['string']); + + expect(tRef.getInt('int'), tConfig.getInt('int')); + expect(tRef.getInt('int'), tMap['int']); + + expect(tRef.getDouble('double'), tConfig.getDouble('double')); + expect(tRef.getDouble('double'), tMap['double']); + + expect(tRef.getBool('bool'), tConfig.getBool('bool')); + expect(tRef.getBool('bool'), tMap['bool']); + + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setMany(tMap); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('localConfigString can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockLocalConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createLocalConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(localConfigStringProvider('string')); + expect(tRef, tMap['string']); + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setString('string', tMap['string'] as String); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('localConfigInt can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockLocalConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createLocalConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(localConfigIntProvider('int')); + expect(tRef, tMap['int']); + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setInt('int', tMap['int'] as int); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('localConfigDouble can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockLocalConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createLocalConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(localConfigDoubleProvider('double')); + expect(tRef, tMap['double']); + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setDouble('double', tMap['double'] as double); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('localConfigBool can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + var tMap = { + 'string': 'value1', + 'int': 1, + 'double': 1.0, + 'bool': true, + }; + final tConfig = MockLocalConfig(tMap); + + await doRefTest( + tester, + plugin: Plugin.inline( + createLocalConfig: () => tConfig, + ), + buildCallback: (ref) { + final tRef = ref.watch(localConfigBoolProvider('bool')); + expect(tRef, tMap['bool']); + tShouldCall(); + }, + testCallback: (tester, app) async { + tMap = { + 'string': 'value2', + 'int': 2, + 'double': 2.0, + 'bool': false, + }; + await tConfig.setBool('bool', tMap['bool'] as bool); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('remoteMessaging can be accessed and watched', (tester) async { + await doRefTest( + tester, + plugin: Plugin.inline( + createRemoteMessaging: () => MockRemoteMessaging( + messages: () async* { + yield const RemoteMessage( + notification: RemoteMessageNotification( + title: 'title', + body: 'body', + ), + ); + }, + tokenStream: () => Stream.value('token2'), + getToken: () => Future.value('token1'), + ), + ), + buildCallback: (ref) { + final tRef = ref.watch(remoteMessagingProvider); + expect(tRef, getApp().remoteMessaging); + }, + ); + }); + + testWidgets('remoteMessagingMessages can be accessed and watched', + (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + final tStreamController = StreamController(); + RemoteMessage? tMessage; + + await doRefTest( + tester, + plugin: Plugin.inline( + createRemoteMessaging: () => MockRemoteMessaging( + messages: () => tStreamController.stream, + ), + ), + buildCallback: (ref) { + tShouldCall(); + final tRef = ref.watch(remoteMessagingMessagesProvider); + + if (tMessage == null) { + expect(tRef.hasValue, isFalse); + expect(tRef.isLoading, isTrue); + } else { + expect(tRef.hasValue, isTrue); + expect(tRef.requireValue.notification!.title, equals('title')); + } + }, + testCallback: (tester, app) async { + tMessage = const RemoteMessage( + notification: RemoteMessageNotification( + title: 'title', + body: 'body', + ), + ); + tStreamController.add(tMessage!); + await tester.pumpAndSettle(); + }, + ); + + tStreamController.close(); + }); + + testWidgets('remoteMessagingTokens can be accessed and watched', + (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2, max: 3); + final tStreamController = StreamController(); + String? tToken = 'token1'; + + await doRefTest( + tester, + plugin: Plugin.inline( + createRemoteMessaging: () => MockRemoteMessaging( + tokenStream: () => tStreamController.stream, + getToken: () => Future.value('token1'), + ), + ), + buildCallback: (ref) { + tShouldCall(); + final tRef = ref.watch(remoteMessagingTokensProvider); + + if (tRef.isLoading) { + return; + } + + expect(tRef.hasValue, isTrue); + expect(tRef.requireValue, equals(tToken)); + }, + testCallback: (tester, app) async { + tToken = 'token2'; + tStreamController.add(tToken); + await tester.pumpAndSettle(); + }, + ); + + tStreamController.close(); + }); + + testWidgets('analytics can be accessed', (tester) async { + await doRefTest( + tester, + buildCallback: (ref) { + expect(ref.read(analyticsProvider), getApp().analytics); + }, + ); + }); + + testWidgets('globalAnalyticsContext can be accessed', (tester) async { + await doRefTest( + tester, + buildCallback: (ref) { + expect(ref.read(globalAnalyticsContextProvider), + getApp().analytics.globalContext); + }, + ); + }); + + testWidgets('networkInformation can be accessed and watched', (tester) async { + final tShouldCall = expectAsync0(() => null, count: 2); + NetworkConnectivity tConnectivity = NetworkConnectivity.none; + + await doRefTest( + tester, + buildCallback: (ref) { + tShouldCall(); + final tRef = ref.watch(networkInformationProvider); + + expect(tRef.connectivity, equals(tConnectivity)); + }, + testCallback: (tester, app) async { + tConnectivity = NetworkConnectivity.wifi; + await app.network.testChangeConnectivity(tConnectivity); + await tester.pumpAndSettle(); + }, + ); + }); + + testWidgets('packageInfo can be accessed', (tester) async { + await doRefTest( + tester, + buildCallback: (ref) { + expect(ref.read(packageInfoProvider), getApp().package); + }, + ); + }); + + testWidgets('deviceInfo can be accessed', (tester) async { + await doRefTest( + tester, + buildCallback: (ref) { + expect(ref.read(deviceInfoProvider), getApp().device); + }, + ); + }); +} + +class TesterPageData { + final void Function(WidgetRef ref)? callback; + + TesterPageData(this.callback); +} + +class TesterPage extends StandardPage { + @override + Widget buildPage(BuildContext context) { + return Consumer(builder: (context, ref, child) { + if (pageData.callback != null) { + pageData.callback!(ref); + } + + return const SizedBox.expand(); + }); + } +} + +class _MockStreamHandler extends MockStreamHandler { + _MockStreamHandler(this.handler); + + final TestMockStreamHandler? handler; + + @override + void onCancel(Object? arguments) { + handler?.onCancel(arguments); + } + + @override + void onListen(Object? arguments, MockStreamHandlerEventSink events) { + handler?.onListen(arguments, _MockStreamHandlerEventSink(other: events)); + } +} + +class _MockStreamHandlerEventSink extends TestMockStreamHandlerEventSink { + final MockStreamHandlerEventSink other; + + _MockStreamHandlerEventSink({ + required this.other, + }); + + @override + void endOfStream() { + other.endOfStream(); + } + + @override + void error({required String code, String? message, Object? details}) { + other.error(code: code, message: message, details: details); + } + + @override + void success(Object? event) { + other.success(event); + } +} diff --git a/packages/patapata_sentry/.gitignore b/packages/patapata_sentry/.gitignore new file mode 100644 index 0000000..a247422 --- /dev/null +++ b/packages/patapata_sentry/.gitignore @@ -0,0 +1,75 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/patapata_sentry/.metadata b/packages/patapata_sentry/.metadata new file mode 100644 index 0000000..4311ca2 --- /dev/null +++ b/packages/patapata_sentry/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 3595343e20a61ff16d14e8ecc25f364276bb1b8b + channel: stable + +project_type: package diff --git a/packages/patapata_sentry/CHANGELOG.md b/packages/patapata_sentry/CHANGELOG.md new file mode 100644 index 0000000..3a10bc5 --- /dev/null +++ b/packages/patapata_sentry/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- initial release \ No newline at end of file diff --git a/packages/patapata_sentry/LICENSE b/packages/patapata_sentry/LICENSE new file mode 100644 index 0000000..ed4e6a9 --- /dev/null +++ b/packages/patapata_sentry/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 GREE, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/patapata_sentry/README.md b/packages/patapata_sentry/README.md new file mode 100644 index 0000000..23b9965 --- /dev/null +++ b/packages/patapata_sentry/README.md @@ -0,0 +1,74 @@ +
    +

    Patapata - Sentry

    +

    + Add support for Sentry to your Patapata app. +

    +
    + +--- + +## About +This package is a plugin for [Patapata](https://pub.dev/packages/patapata_core) that adds support for [Sentry](https://sentry.io) to your Patapata app. + +It will integrate with the Patapata error handling and log systems and automatically report errors to Sentry. +It will also integrate with Patapata's User system and automatically send user information to Sentry. + +## Getting started + +1. Add the dependency to your `pubspec.yaml` file + +```sh +flutter pub add patapata_sentry +``` + +2. Import the package + +```dart +import 'package:patapata_sentry/patapata_sentry.dart'; +``` + +3. Activate the plugin + +```dart +/// This Environment takes sentry configuration from environment variables. +/// Pass environment variables to your app using the `--dart-define` flag. +class Environment with SentryPluginEnvironment { + const Environment(); + + /// This is the DSN for the Sentry project you want to send errors to. + @override + String get sentryDSN => const String.fromEnvironment('SENTRY_DSN'); + + /// Just as an example, we're using environment variables to configure + /// Sentry's environment and dist. You can modify any of the options. + @override + FutureOr Function(SentryFlutterOptions)? get sentryOptions => + (options) => options + ..environment = const String.fromEnvironment('SENTRY_ENVIRONMENT') + ..dist = const String.fromEnvironment('SENTRY_DIST'); +} + +void main() { + App( + environment: const Environment(), + plugins: [ + SentryPlugin(), + ], + ) + .run(); +} +``` + +## Extra configuration + +You can change the traces sample rate and and sample rate via RemoteConfig by setting the following keys: +- `patapata_sentry_plugin_tracessamplerate`: (`SentryOptions.tracesSampleRate`)[https://pub.dev/documentation/sentry_flutter/latest/sentry_flutter/SentryOptions/tracesSampleRate.html] +- `patapata_sentry_plugin_samplerate`: (`SentryOptions.sampleRate`)[https://pub.dev/documentation/sentry_flutter/latest/sentry_flutter/SentryOptions/sampleRate.html] + +## Contributing + +Check out the [CONTRIBUTING](https://github.com/gree/patapata/blob/main/CONTRIBUTING.md) guide to get started. + +## License + +[See the LICENSE file](https://github.com/gree/patapata/blob/main/packages/patapata_sentry/LICENSE) \ No newline at end of file diff --git a/packages/patapata_sentry/analysis_options.yaml b/packages/patapata_sentry/analysis_options.yaml new file mode 100644 index 0000000..193e69d --- /dev/null +++ b/packages/patapata_sentry/analysis_options.yaml @@ -0,0 +1,9 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_dynamic_calls: true + +analyzer: + exclude: + - example/** \ No newline at end of file diff --git a/packages/patapata_sentry/dartdoc_options.yaml b/packages/patapata_sentry/dartdoc_options.yaml new file mode 100644 index 0000000..309c133 --- /dev/null +++ b/packages/patapata_sentry/dartdoc_options.yaml @@ -0,0 +1,8 @@ +# Copyright (c) GREE, Inc. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +dartdoc: + include: + - patapata_sentry \ No newline at end of file diff --git a/packages/patapata_sentry/lib/patapata_sentry.dart b/packages/patapata_sentry/lib/patapata_sentry.dart new file mode 100644 index 0000000..ef3efd6 --- /dev/null +++ b/packages/patapata_sentry/lib/patapata_sentry.dart @@ -0,0 +1,266 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +library patapata_sentry; + +import 'package:flutter/widgets.dart'; +import 'package:patapata_core/patapata_core.dart'; +import 'package:patapata_core/patapata_core_libs.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +/// Configuration for [SentryPlugin]. +mixin SentryPluginEnvironment { + /// Destination for sending events to Sentry. + String get sentryDSN; + + /// Options to set for Sentry SDK. + FutureOr Function(SentryFlutterOptions)? get sentryOptions; +} + +/// This plugin provides functionality for Sentry, which monitors application errors. +class SentryPlugin extends Plugin { + StreamSubscription? _onReportSubscription; + + @override + bool get requireRemoteConfig => true; + + SentryPluginEnvironment get _environment => + app.environment as SentryPluginEnvironment; + + /// Initializes the [SentryPlugin]. + @override + FutureOr init(App app) async { + if (app.environment is! SentryPluginEnvironment) { + return false; + } + + await super.init(app); + + if (_environment.sentryDSN.isEmpty) { + return false; + } + + await SentryFlutter.init( + (options) { + options + ..dsn = _environment.sentryDSN + ..debug = false + ..enableBreadcrumbTrackingForCurrentPlatform() + ..tracesSampleRate = app.remoteConfig.getDouble( + 'patapata_sentry_plugin_tracessamplerate', + defaultValue: 1.0, + ) + ..sampleRate = app.remoteConfig.getDouble( + 'patapata_sentry_plugin_samplerate', + defaultValue: 1.0, + ) + ..beforeSend = _beforeSend + ..beforeBreadcrumb = _beforeBreadcrumb + ..enablePrintBreadcrumbs = false; + + options.sdk.addIntegration('patapata'); + + if (_environment.sentryOptions != null) { + _environment.sentryOptions!(options); + } + }, + ); + + _onReportSubscription = app.log.reports.listen(_onReport); + + app.user.addSynchronousChangeListener(_onUserChanged); + + return true; + } + + @override + FutureOr dispose() async { + app.user.removeSynchronousChangeListener(_onUserChanged); + + await super.dispose(); + await _onReportSubscription!.cancel(); + + _onReportSubscription = null; + } + + SentryEvent? _beforeSend(SentryEvent event, {dynamic hint}) { + if (disposed) { + // Don't send if we are disabled. + return null; + } + + // Align the Sentry logging level to the app logging level. + if (_sentryLevelToLoggingLevel(event.level!) < app.log.level) { + return null; + } + + return event; + } + + Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, {dynamic hint}) { + if (breadcrumb == null || disposed) { + // Don't send if we are disabled. + return null; + } + + // Align the Sentry logging level to the app logging level. + if (_sentryLevelToLoggingLevel(breadcrumb.level!) < app.log.level) { + return null; + } + + return breadcrumb; + } + + @override + List get navigatorObservers => [SentryNavigatorObserver()]; + + FutureOr _onUserChanged(User user, UserChangeData changes) { + final tId = changes.getIdFor(); + final tProperties = changes.getPropertiesFor(); + + Sentry.configureScope((scope) { + scope.setUser(SentryUser( + id: tId ?? '', + username: scope.user?.username, + email: scope.user?.email, + ipAddress: scope.user?.ipAddress, + data: tProperties, + )); + }); + } + + Level _sentryLevelToLoggingLevel(SentryLevel sentryLevel) { + switch (sentryLevel) { + case SentryLevel.fatal: + return Level.SHOUT; + case SentryLevel.error: + return Level.SEVERE; + case SentryLevel.warning: + return Level.WARNING; + case SentryLevel.info: + return Level.INFO; + case SentryLevel.debug: + default: + return Level.FINE; + } + } + + SentryLevel _loggingLevelToSentryLevel(Level level) { + if (level >= Level.SHOUT) { + return SentryLevel.fatal; + } else if (level >= Level.SEVERE) { + return SentryLevel.error; + } else if (level >= Level.WARNING) { + return SentryLevel.warning; + } else if (level >= Level.INFO) { + return SentryLevel.info; + } else { + return SentryLevel.debug; + } + } + + void _onReport(ReportRecord record) async { + if (record.mechanism == Log.kFlutterErrorMechanism) { + // Sentry does it by itself, automatically. + return; + } + + final tSentryLevel = _loggingLevelToSentryLevel(record.level); + final Map tExtra = record.extra != null + ? Map.from( + record.extra!.map((key, value) => MapEntry(key, value ?? ''))) + : {}; + SentryException? tException; + final tMechanism = Mechanism(type: record.mechanism, handled: true); + final tError = record.error; + + tExtra['patapataReportRecord'] = record; + + if (record.mechanism == Log.kNativeMechanism && tError is NativeThrowable) { + tException = SentryException( + type: tError.type ?? 'patapata.NativeThrowable', + value: tError.message ?? tError.toString(), + mechanism: tMechanism, + stackTrace: SentryStackTrace( + frames: List.of( + ((NativeThrowable? nativeThrowable) sync* { + while (nativeThrowable != null) { + bool tProcessedFirst = false; + + if (nativeThrowable.chain != null) { + for (var tTrace in nativeThrowable.chain!.traces) { + for (var tFrame in tTrace.frames) { + yield SentryStackFrame( + package: tFrame.library, + lineNo: tFrame.line, + colNo: tFrame.column, + native: true, + function: tFrame.member, + absPath: tFrame.uri.toString(), + ); + } + + if (!tProcessedFirst) { + tProcessedFirst = true; + yield SentryStackFrame(absPath: ''); + } + } + } + + nativeThrowable = nativeThrowable.cause; + + if (nativeThrowable != null) { + yield SentryStackFrame(absPath: ''); + } + } + })(tError))), + ); + } + + tExtra.addAll({ + if (record.object != null) 'LogRecord.object': record.object!.toString(), + 'LogRecord.sequenceNumber': record.sequenceNumber, + }); + + if (record.level >= Level.WARNING) { + final tEvent = SentryEvent( + level: tSentryLevel, + timestamp: record.time.toUtc(), + fingerprint: record.fingerprint != null + ? [SentryEvent.defaultFingerprint, ...record.fingerprint!] + : null, + logger: record.loggerName, + message: SentryMessage(record.message), + throwable: tError, + exceptions: tException != null ? [tException] : null, + ); + + await Sentry.captureEvent( + tEvent, + stackTrace: record.stackTrace, + hint: Hint.withMap(tExtra), + ); + } else { + tExtra.addAll({ + if (tError != null) 'LogRecord.error': tError.toString(), + if (record.stackTrace != null) + 'LogRecord.stackTrace': record.stackTrace!, + 'LogRecord.loggerName': record.loggerName, + }); + + Sentry.addBreadcrumb( + Breadcrumb( + level: tSentryLevel, + timestamp: record.time.toUtc(), + message: record.message, + data: tExtra, + category: record.loggerName, + type: tError != null ? 'error' : null, + ), + hint: Hint.withMap(tExtra), + ); + } + } +} diff --git a/packages/patapata_sentry/pubspec.yaml b/packages/patapata_sentry/pubspec.yaml new file mode 100644 index 0000000..a30abcc --- /dev/null +++ b/packages/patapata_sentry/pubspec.yaml @@ -0,0 +1,25 @@ +name: patapata_sentry +description: This package is a plugin for Patapata that adds support for Sentry to your Patapata app. +version: 1.0.0 +homepage: https://github.com/gree/patapata +repository: https://github.com/gree/patapata/packages/patapata_sentry + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.13.0" + +dependencies: + flutter: + sdk: flutter + + patapata_core: ^1.0.0 + + sentry_flutter: ^7.9.0 + sentry: ^7.9.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.2 + +flutter: diff --git a/packages/patapata_sentry/test/patapata_sentry_test.dart b/packages/patapata_sentry/test/patapata_sentry_test.dart new file mode 100644 index 0000000..0efedcd --- /dev/null +++ b/packages/patapata_sentry/test/patapata_sentry_test.dart @@ -0,0 +1,12 @@ +// Copyright (c) GREE, Inc. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Empty test pass', () {}); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..ea3a502 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,317 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + ansi_styles: + dependency: transitive + description: + name: ansi_styles + sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a" + url: "https://pub.dev" + source: hosted + version: "0.3.2+1" + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + cli_launcher: + dependency: transitive + description: + name: cli_launcher + sha256: "5e7e0282b79e8642edd6510ee468ae2976d847a0a29b3916e85f5fa1bfe24005" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + conventional_commit: + dependency: transitive + description: + name: conventional_commit + sha256: dec15ad1118f029c618651a4359eb9135d8b88f761aa24e4016d061cd45948f2 + url: "https://pub.dev" + source: hosted + version: "0.6.0+1" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + graphs: + dependency: transitive + description: + name: graphs + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + http: + dependency: transitive + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + melos: + dependency: "direct dev" + description: + name: melos + sha256: "7266e9fc9fee5f4a0c075e5cec375c00736dfc944358f533b740b93b3d8d681e" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + meta: + dependency: transitive + description: + name: meta + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + url: "https://pub.dev" + source: hosted + version: "1.10.0" + mustache_template: + dependency: transitive + description: + name: mustache_template + sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" + source: hosted + version: "1.8.3" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + prompts: + dependency: transitive + description: + name: prompts + sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "54e8dc865349059ebe7f163d6acce7c89eb958b8047e6d6e80ce93b13d7c9e60" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + pubspec: + dependency: transitive + description: + name: pubspec + sha256: f534a50a2b4d48dc3bc0ec147c8bd7c304280fff23b153f3f11803c4d49d927e + url: "https://pub.dev" + source: hosted + version: "2.3.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + uri: + dependency: transitive + description: + name: uri + sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "1579d4a0340a83cf9e4d580ea51a16329c916973bffd5bd4b45e911b25d46bfd" + url: "https://pub.dev" + source: hosted + version: "2.1.1" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..1d1ef84 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,6 @@ +name: patapata_workspace + +environment: + sdk: ">=2.18.0 <4.0.0" +dev_dependencies: + melos: ^4.1.0 diff --git a/tools/dartdoc_all.sh b/tools/dartdoc_all.sh new file mode 100755 index 0000000..7a1956b --- /dev/null +++ b/tools/dartdoc_all.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +for i in $(ls packages) ; do + pushd packages/"$i" + + dart doc . + + popd +done + +pushd packages/patapata_core/android + +gradle clean + +./gradlew dokkaHtml + +popd + +pushd packages/patapata_core/ios + +swift doc generate . --base-url http://localhost:8080/ --module-name patapata_core --output doc --format html + +popd \ No newline at end of file diff --git a/tools/pub_get_all.sh b/tools/pub_get_all.sh new file mode 100755 index 0000000..78d99c6 --- /dev/null +++ b/tools/pub_get_all.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +for i in $(ls packages) ; do + pushd packages/"$i" + + flutter pub get + + popd +done diff --git a/tools/pub_upgrade_all.sh b/tools/pub_upgrade_all.sh new file mode 100755 index 0000000..5290667 --- /dev/null +++ b/tools/pub_upgrade_all.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +for i in $(ls packages) ; do + pushd packages/"$i" + + flutter pub upgrade + + popd +done diff --git a/tools/test_all.sh b/tools/test_all.sh new file mode 100755 index 0000000..4861f3d --- /dev/null +++ b/tools/test_all.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +for i in $(ls packages) ; do + pushd packages/"$i" + + flutter test + + popd +done