diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 7c56964..1dc6cf7 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.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! + + 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/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..9865c0b --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,79 @@ +PODS: + - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - open_mail (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - receive_sharing_intent (1.8.1): + - Flutter + - restart_app (0.0.1): + - Flutter + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - open_mail (from `.symlinks/plugins/open_mail/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) + - restart_app (from `.symlinks/plugins/restart_app/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + open_mail: + :path: ".symlinks/plugins/open_mail/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + receive_sharing_intent: + :path: ".symlinks/plugins/receive_sharing_intent/ios" + restart_app: + :path: ".symlinks/plugins/restart_app/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + open_mail: b6b41374f5000a2ea1edc09ffe39d9e434e2747a + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 + restart_app: 9cda5378aacc5000e3f66ee76a9201534e7d3ecf + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f37b276..3342015 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,9 +8,11 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 282C99441982CA8501094594 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B330A4E7EB0D7F3F14A1C113 /* Pods_RunnerTests.framework */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7901162544EAEF4A1612DA25 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EF5EE3B86BFC740FF7A25E4 /* Pods_Runner.framework */; }; 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 */; }; @@ -40,14 +42,19 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 05FEB0F37A3ECEDFE5C8DE9C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 0EF5EE3B86BFC740FF7A25E4 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; 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; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4BC94B6C6F4DBB54399B5B2D /* 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 = ""; }; + 5159CDFF4E027972758E5D62 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.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 = ""; }; + 951F0BE69A05C23BE7D9369B /* 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; }; @@ -55,19 +62,40 @@ 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 = ""; }; + B330A4E7EB0D7F3F14A1C113 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B8D649AE805A0A4FC977F5C6 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + D7EBFC691C8BD61A968BD6BB /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 94D13764D2D87371C66A842E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 282C99441982CA8501094594 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7901162544EAEF4A1612DA25 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 119C1BA1DE158960BA4E8DFC /* Frameworks */ = { + isa = PBXGroup; + children = ( + 0EF5EE3B86BFC740FF7A25E4 /* Pods_Runner.framework */, + B330A4E7EB0D7F3F14A1C113 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -76,6 +104,20 @@ path = RunnerTests; sourceTree = ""; }; + 85B3F19C60BA9BAA96798E69 /* Pods */ = { + isa = PBXGroup; + children = ( + 4BC94B6C6F4DBB54399B5B2D /* Pods-Runner.debug.xcconfig */, + 951F0BE69A05C23BE7D9369B /* Pods-Runner.release.xcconfig */, + D7EBFC691C8BD61A968BD6BB /* Pods-Runner.profile.xcconfig */, + B8D649AE805A0A4FC977F5C6 /* Pods-RunnerTests.debug.xcconfig */, + 05FEB0F37A3ECEDFE5C8DE9C /* Pods-RunnerTests.release.xcconfig */, + 5159CDFF4E027972758E5D62 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +136,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 85B3F19C60BA9BAA96798E69 /* Pods */, + 119C1BA1DE158960BA4E8DFC /* Frameworks */, ); sourceTree = ""; }; @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + D1F7CF24C81B31328E3248D7 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 94D13764D2D87371C66A842E /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 629A2E46645A867C3067D8CA /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 83913DFF1DBDF244998F7213 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -238,6 +286,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 629A2E46645A867C3067D8CA /* [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; + }; + 83913DFF1DBDF244998F7213 /* [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; @@ -253,6 +340,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + D1F7CF24C81B31328E3248D7 /* [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-RunnerTests-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; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -346,7 +455,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -378,6 +487,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B8D649AE805A0A4FC977F5C6 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +505,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 05FEB0F37A3ECEDFE5C8DE9C /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +521,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5159CDFF4E027972758E5D62 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -472,7 +584,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -523,7 +635,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/main.dart b/lib/main.dart index c326299..bd15208 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,22 +28,29 @@ import 'pages/insights_page.dart'; import 'pages/log_period_page.dart'; import 'pages/profile_page.dart'; -final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); +final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); Future main() async { WidgetsFlutterBinding.ensureInitialized(); await NotificationService().init(); + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); // Set system UI overlay style globally SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, // Background behind status bar (top) - statusBarBrightness: Brightness.dark, // iOS: dark status bar content (light icons/text) - statusBarIconBrightness: Brightness.light, // Android: light icons (dark background) - systemNavigationBarColor: Colors.black, // Android: background color of bottom navigation bar - systemNavigationBarDividerColor: Colors.black, // Android: divider above navbar (optional) - systemNavigationBarIconBrightness: Brightness.light, // Android: light icons for dark navbar + statusBarBrightness: + Brightness.dark, // iOS: dark status bar content (light icons/text) + statusBarIconBrightness: + Brightness.light, // Android: light icons (dark background) + systemNavigationBarColor: + Colors.black, // Android: background color of bottom navigation bar + systemNavigationBarDividerColor: + Colors.black, // Android: divider above navbar (optional) + systemNavigationBarIconBrightness: + Brightness.light, // Android: light icons for dark navbar systemStatusBarContrastEnforced: false, // Allow custom navbar styling systemNavigationBarContrastEnforced: false, // Allow custom navbar styling ), @@ -62,7 +69,10 @@ Future main() async { ChangeNotifierProvider(create: (_) => UserProvider()), ChangeNotifierProvider(create: (_) => SettingsProvider()), ], - child: PeriodTrackerApp(showOnboarding: !onBoardingComplete, isAfterRestore: isAfterRestore), + child: PeriodTrackerApp( + showOnboarding: !onBoardingComplete, + isAfterRestore: isAfterRestore, + ), ), ); } @@ -70,7 +80,11 @@ Future main() async { class PeriodTrackerApp extends StatefulWidget { final bool showOnboarding; final bool isAfterRestore; - const PeriodTrackerApp({super.key, required this.showOnboarding, required this.isAfterRestore}); + const PeriodTrackerApp({ + super.key, + required this.showOnboarding, + required this.isAfterRestore, + }); @override State createState() => _PeriodTrackerAppState(); @@ -78,22 +92,18 @@ class PeriodTrackerApp extends StatefulWidget { class _PeriodTrackerAppState extends State { late StreamSubscription _intentSub; + late final GoRouter _router; final List _sharedFiles = []; @override void initState() { super.initState(); + _router = _buildRouter(); // Listen to media sharing coming from outside the app while the app is in the memory _intentSub = ReceiveSharingIntent.instance.getMediaStream().listen( (value) { - setState(() { - _sharedFiles.clear(); - _sharedFiles.addAll(value); - if (_sharedFiles.isNotEmpty) { - _setFileShared(true); - } - }); + _handleSharedFiles(value); }, onError: (err) { // TODO: handle error @@ -102,15 +112,7 @@ class _PeriodTrackerAppState extends State { // Get the media sharing coming from outside the app while the app is closed. ReceiveSharingIntent.instance.getInitialMedia().then((value) { - setState(() { - _sharedFiles.clear(); - _sharedFiles.addAll(value); - if (_sharedFiles.isNotEmpty) { - _setFileShared(true); - } - - ReceiveSharingIntent.instance.reset(); - }); + _handleSharedFiles(value, resetIntent: true); }); } @@ -124,25 +126,51 @@ class _PeriodTrackerAppState extends State { await setFileShared(value); } - @override - Widget build(BuildContext context) { - final GoRouter router = GoRouter( + void _handleSharedFiles(List value, {bool resetIntent = false}) { + setState(() { + _sharedFiles.clear(); + _sharedFiles.addAll(value); + }); + + if (_sharedFiles.isNotEmpty) { + _setFileShared(true); + _router.go('/restore'); + } + + if (resetIntent) { + ReceiveSharingIntent.instance.reset(); + } + } + + GoRouter _buildRouter() { + return GoRouter( initialLocation: widget.showOnboarding ? '/onboarding' : '/', routes: [ GoRoute( path: '/', - builder: (context, state) => MainNavigation(isAfterRestore: widget.isAfterRestore), + builder: (context, state) => + MainNavigation(isAfterRestore: widget.isAfterRestore), routes: [ GoRoute( path: 'log', builder: (context, state) { - final bool isEditing = state.uri.queryParameters['isEditing'] == 'true'; + final bool isEditing = + state.uri.queryParameters['isEditing'] == 'true'; final String? periodId = state.uri.queryParameters['periodId']; - final Period? period = periodId != null ? context.read().getPeriodById(int.parse(periodId)) : null; - final DateTime? focusedDay = state.uri.queryParameters['focusedDay'] != null + final Period? period = periodId != null + ? context.read().getPeriodById( + int.parse(periodId), + ) + : null; + final DateTime? focusedDay = + state.uri.queryParameters['focusedDay'] != null ? DateTime.parse(state.uri.queryParameters['focusedDay']!) : null; - return LogPeriodPage(isEditing: isEditing, period: period, focusedDay: focusedDay); + return LogPeriodPage( + isEditing: isEditing, + period: period, + focusedDay: focusedDay, + ); }, ), GoRoute( @@ -168,35 +196,43 @@ class _PeriodTrackerAppState extends State { GoRoute( path: '/onboarding', builder: (context, state) => const OnboardingScreen(), - routes: [GoRoute(path: 'restore', builder: (context, state) => const OnboardingRestoreDataPage())], + routes: [ + GoRoute( + path: 'restore', + builder: (context, state) => const OnboardingRestoreDataPage(), + ), + ], ), GoRoute( path: '/help', builder: (context, state) { - final String initialTab = state.uri.queryParameters['initialPage'] ?? 'restore'; + final String initialTab = + state.uri.queryParameters['initialPage'] ?? 'restore'; return RestoreHelpPage(initialTab: initialTab); }, ), GoRoute( path: '/restore', - builder: (context, state) => RestoreDataPreviewPage(sharedFiles: _sharedFiles), + builder: (context, state) => + RestoreDataPreviewPage(sharedFiles: _sharedFiles), ), ], - redirect: (context, state) async { - final bool fileShared = await getFileShared() == true; - if (_sharedFiles.isNotEmpty && fileShared) { - return '/restore'; - } - return null; // no redirection - }, errorBuilder: (context, state) { - return widget.showOnboarding ? const OnboardingScreen() : MainNavigation(isAfterRestore: widget.isAfterRestore); + return widget.showOnboarding + ? const OnboardingScreen() + : MainNavigation(isAfterRestore: widget.isAfterRestore); }, ); + } - // disable landscape mode - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - return MaterialApp.router(title: 'Period Tracker', theme: appTheme, routerConfig: router, debugShowCheckedModeBanner: kDebugMode); + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'Period Tracker', + theme: appTheme, + routerConfig: _router, + debugShowCheckedModeBanner: kDebugMode, + ); } } @@ -222,7 +258,9 @@ class _MainNavigationState extends State { barrierDismissible: false, builder: (context) => AlertDialog( title: const Text('Data restored successfully 🎉'), - content: Text('All your data has been successfully restored from the $kBackupFileName file'), + content: Text( + 'All your data has been successfully restored from the $kBackupFileName file', + ), actions: [ TextButton( onPressed: () async { @@ -239,37 +277,56 @@ class _MainNavigationState extends State { } } - final List pages = [HomePage(), InsightsPage(), ProfilePage()]; + final List pages = const [ + HomePage(key: PageStorageKey('home-page')), + InsightsPage(key: PageStorageKey('insights-page')), + ProfilePage(key: PageStorageKey('profile-page')), + ]; final List appBarTitles = ['Home', 'Insights', 'Profile']; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(appBarTitles[_selectedIndex], style: Theme.of(context).textTheme.titleMedium), + title: Text( + appBarTitles[_selectedIndex], + style: Theme.of(context).textTheme.titleMedium, + ), centerTitle: true, backgroundColor: Colors.transparent, ), extendBodyBehindAppBar: true, body: SafeArea( - child: Padding(padding: EdgeInsets.all(8.0), child: pages[_selectedIndex]), + child: Padding( + padding: EdgeInsets.all(8.0), + child: IndexedStack(index: _selectedIndex, children: pages), + ), ), bottomNavigationBar: NavigationBar( destinations: [ NavigationDestination( icon: Icon(Icons.home_rounded), - selectedIcon: Icon(Icons.home_rounded, color: Theme.of(context).colorScheme.onPrimary), + selectedIcon: Icon( + Icons.home_rounded, + color: Theme.of(context).colorScheme.onPrimary, + ), label: 'Home', tooltip: null, ), NavigationDestination( icon: Icon(Icons.bar_chart_rounded), - selectedIcon: Icon(Icons.bar_chart_rounded, color: Theme.of(context).colorScheme.onPrimary), + selectedIcon: Icon( + Icons.bar_chart_rounded, + color: Theme.of(context).colorScheme.onPrimary, + ), label: 'Insights', ), NavigationDestination( icon: Icon(Icons.person_rounded), - selectedIcon: Icon(Icons.person_rounded, color: Theme.of(context).colorScheme.onPrimary), + selectedIcon: Icon( + Icons.person_rounded, + color: Theme.of(context).colorScheme.onPrimary, + ), label: 'Profile', ), ], diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 40e416e..827a91b 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -8,7 +8,6 @@ import 'package:period_tracker/models/user_model.dart'; import 'package:period_tracker/providers/period_provider.dart'; import 'package:period_tracker/providers/settings_provider.dart'; import 'package:period_tracker/providers/user_provider.dart'; -import 'package:period_tracker/services/period_service.dart'; import 'package:period_tracker/shared_preferences/shared_preferences.dart'; import 'package:period_tracker/utils/date_time_helper.dart'; import 'package:provider/provider.dart'; @@ -22,7 +21,7 @@ class HomePage extends StatefulWidget { State createState() => _HomePageState(); } -class _HomePageState extends State with TickerProviderStateMixin { +class _HomePageState extends State { DateTime? _rangeStart; DateTime? _rangeEnd; late DateTime _selectedDay; @@ -47,24 +46,44 @@ class _HomePageState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - final periodProvider = Provider.of(context); - final List periods = context.watch().periods; + final periodProvider = context.watch(); + final List periods = periodProvider.periods; final Settings? settings = context.watch().settings; final User? user = context.watch().user; - final List next3PeriodDates = periodProvider.getNext3PeriodDates(settings?.predictionMode == 'dynamic', user?.cycleLength); - final DateTime? nextPeriodDate = next3PeriodDates.isNotEmpty ? next3PeriodDates[0] : null; + final List next3PeriodDates = periodProvider.getNext3PeriodDates( + settings?.predictionMode == 'dynamic', + user?.cycleLength, + ); + final DateTime? nextPeriodDate = next3PeriodDates.isNotEmpty + ? next3PeriodDates[0] + : null; + final int periodDuration = (user?.periodLength ?? kDefaultPeriodLength) - 1; + final _CalendarDayLookup calendarDayLookup = _buildCalendarDayLookup( + periods, + next3PeriodDates, + periodDuration, + ); final DateTime now = DateTime.now(); - final int currentCycleDay = periodProvider.getCurrentCycleDay(DateTime.utc(now.year, now.month, now.day)); + final int currentCycleDay = periodProvider.getCurrentCycleDay( + DateTime.utc(now.year, now.month, now.day), + ); double? cycleLength; if (settings?.predictionMode == 'dynamic') { - cycleLength = periodProvider.getAverageCycleLength(userCycleLength: user?.cycleLength); // provide userCycleLength if available + cycleLength = periodProvider.getAverageCycleLength( + userCycleLength: user?.cycleLength, + ); // provide userCycleLength if available } else { cycleLength = user?.cycleLength.toDouble(); } - final status = periodProvider.getStatusMessage(Theme.of(context).colorScheme.tertiary, nextPeriodDate); + final status = periodProvider.getStatusMessage( + Theme.of(context).colorScheme.tertiary, + nextPeriodDate, + ); + final Period? selectedPeriod = + calendarDayLookup.loggedDays[_dayKey(_selectedDay)]?.period; final bool showProgressBar = cycleLength != null; double progress = 0; @@ -77,8 +96,10 @@ class _HomePageState extends State with TickerProviderStateMixin { final TextEditingController messageController = TextEditingController(); final TextEditingController phoneController = TextEditingController(); String? userDbPhoneNumber = user?.partnerPhoneNumber; - String userIsoCountryCode = userDbPhoneNumber?.split('|')[0] ?? kDefaultIsoCountryCode; // SI - String userCountryCode = userDbPhoneNumber?.split('|')[1] ?? kDefaultCountryCode; // +386 + String userIsoCountryCode = + userDbPhoneNumber?.split('|')[0] ?? kDefaultIsoCountryCode; // SI + String userCountryCode = + userDbPhoneNumber?.split('|')[1] ?? kDefaultCountryCode; // +386 String userPhoneNumber = userDbPhoneNumber?.split('|')[2] ?? ''; // Prefill phone number if it exists @@ -88,11 +109,11 @@ class _HomePageState extends State with TickerProviderStateMixin { showDialog( context: context, - builder: (context) { + builder: (dialogContext) { return AlertDialog( title: const Text('Order boyfriend'), content: SizedBox( - width: MediaQuery.of(context).size.width, + width: MediaQuery.of(dialogContext).size.width, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -102,7 +123,9 @@ class _HomePageState extends State with TickerProviderStateMixin { controller: phoneController, decoration: InputDecoration( hintText: 'Phone number', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), ), initialCountryCode: userIsoCountryCode, onCountryChanged: (value) { @@ -115,26 +138,36 @@ class _HomePageState extends State with TickerProviderStateMixin { userPhoneNumber = phone.number; }, ), - if (user?.partnerMessageHeading?.isEmpty ?? true) const SizedBox(height: 12), - if (user?.partnerMessageHeading?.isNotEmpty ?? false) const SizedBox(height: 6), + if (user?.partnerMessageHeading?.isEmpty ?? true) + const SizedBox(height: 12), + if (user?.partnerMessageHeading?.isNotEmpty ?? false) + const SizedBox(height: 6), if (user?.partnerMessageHeading?.isNotEmpty ?? false) RichText( text: TextSpan( children: [ - TextSpan(text: 'Title: ', style: DefaultTextStyle.of(context).style), + TextSpan( + text: 'Title: ', + style: DefaultTextStyle.of(dialogContext).style, + ), TextSpan( text: user?.partnerMessageHeading ?? '', - style: DefaultTextStyle.of(context).style.copyWith(fontStyle: FontStyle.italic), + style: DefaultTextStyle.of( + dialogContext, + ).style.copyWith(fontStyle: FontStyle.italic), ), ], ), ), - if (user?.partnerMessageHeading?.isNotEmpty ?? false) const SizedBox(height: 6), + if (user?.partnerMessageHeading?.isNotEmpty ?? false) + const SizedBox(height: 6), TextField( controller: messageController, decoration: InputDecoration( hintText: 'Message', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), ), maxLines: 3, ), @@ -144,14 +177,20 @@ class _HomePageState extends State with TickerProviderStateMixin { ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), - style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.tertiary), + onPressed: () => Navigator.of(dialogContext).pop(), + style: TextButton.styleFrom( + foregroundColor: Theme.of(dialogContext).colorScheme.tertiary, + ), child: const Text('Cancel'), ), TextButton( onPressed: () async { if (userPhoneNumber.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter a phone number'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter a phone number'), + ), + ); return; } @@ -160,7 +199,8 @@ class _HomePageState extends State with TickerProviderStateMixin { name: user?.name, cycleLength: user?.cycleLength ?? 28, periodLength: user?.periodLength ?? 5, - partnerPhoneNumber: '$userIsoCountryCode|$userCountryCode|$userPhoneNumber', + partnerPhoneNumber: + '$userIsoCountryCode|$userCountryCode|$userPhoneNumber', ); // Build SMS message with optional heading @@ -171,20 +211,26 @@ class _HomePageState extends State with TickerProviderStateMixin { // Send SMS final encodedBody = Uri.encodeComponent(smsBody); - final uri = Uri.parse('sms:$userCountryCode$userPhoneNumber?body=$encodedBody'); + final uri = Uri.parse( + 'sms:$userCountryCode$userPhoneNumber?body=$encodedBody', + ); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); - Navigator.of(context).pop(); + if (!dialogContext.mounted) return; + Navigator.of(dialogContext).pop(); } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not open SMS app'))); - } + if (!dialogContext.mounted) return; + ScaffoldMessenger.of(dialogContext).showSnackBar( + const SnackBar(content: Text('Could not open SMS app')), + ); } }, child: const Text('Send'), ), ], - backgroundColor: Theme.of(context).colorScheme.primaryContainer, + backgroundColor: Theme.of( + dialogContext, + ).colorScheme.primaryContainer, ); }, ); @@ -193,6 +239,7 @@ class _HomePageState extends State with TickerProviderStateMixin { return Scaffold( body: SafeArea( child: SingleChildScrollView( + key: const PageStorageKey('home-scroll-view'), child: Column( children: [ Padding( @@ -202,14 +249,23 @@ class _HomePageState extends State with TickerProviderStateMixin { children: [ Row( children: [ - Icon(Icons.calendar_month_rounded, size: 40, color: Theme.of(context).colorScheme.primary), + Icon( + Icons.calendar_month_rounded, + size: 40, + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Next period:', style: Theme.of(context).textTheme.bodyMedium), Text( - nextPeriodDate != null ? DateTimeHelper.displayDate(nextPeriodDate) : 'Not enough data', + 'Next period:', + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + nextPeriodDate != null + ? DateTimeHelper.displayDate(nextPeriodDate) + : 'Not enough data', style: Theme.of(context).textTheme.titleMedium, ), ], @@ -220,17 +276,32 @@ class _HomePageState extends State with TickerProviderStateMixin { onTap: orderBoyfriend, borderRadius: BorderRadius.circular(8), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.favorite_rounded, size: 28, color: Theme.of(context).colorScheme.primary), + Icon( + Icons.favorite_rounded, + size: 28, + color: Theme.of( + context, + ).colorScheme.primary, + ), const SizedBox(height: 4), Text( 'Order boyfriend', - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface), + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), ), ], ), @@ -243,61 +314,99 @@ class _HomePageState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 26), - Text('Current cycle day: $currentCycleDay', style: Theme.of(context).textTheme.bodyMedium), + Text( + 'Current cycle day: $currentCycleDay', + style: Theme.of(context).textTheme.bodyMedium, + ), const SizedBox(height: 8), LinearProgressIndicator( value: progress, - valueColor: AlwaysStoppedAnimation(Theme.of(context).colorScheme.primary), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), minHeight: 8, - backgroundColor: Theme.of(context).colorScheme.secondary, + backgroundColor: Theme.of( + context, + ).colorScheme.secondary, borderRadius: BorderRadius.circular(99), ), ], ), const SizedBox(height: 16), - Text(status.text, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: status.color)), + Text( + status.text, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: status.color), + ), ], ), ), // Calendar section - SizedBox( - // height: 420, // probably don't need fixed height - child: TableCalendar( - headerStyle: HeaderStyle(formatButtonVisible: false), - startingDayOfWeek: StartingDayOfWeek.monday, - firstDay: kFirstCalendarDay, - lastDay: kLastCalendarDay, - focusedDay: _focusedDay, - availableGestures: AvailableGestures.horizontalSwipe, - selectedDayPredicate: (day) => isSameDay(_selectedDay, day), - rangeStartDay: _rangeStart, - rangeEndDay: _rangeEnd, - calendarFormat: CalendarFormat.month, - rangeSelectionMode: RangeSelectionMode.toggledOff, - onDaySelected: (selectedDay, focusedDay) { - setState(() { - if (!isSameDay(_selectedDay, selectedDay)) { - _selectedDay = selectedDay; - _focusedDay = focusedDay; - _rangeStart = null; - } - }); - }, - daysOfWeekHeight: kTableCalendarDaysOfTheWeekHeight, - daysOfWeekStyle: DaysOfWeekStyle( - weekdayStyle: TextStyle(color: Theme.of(context).colorScheme.tertiary), - weekendStyle: TextStyle(color: Theme.of(context).colorScheme.tertiary), - ), - calendarStyle: CalendarStyle(outsideDaysVisible: false), - calendarBuilders: CalendarBuilders( - defaultBuilder: (context, day, focusedDay) => _defaultBuilder(context, day, focusedDay, periods, next3PeriodDates, user), - todayBuilder: (context, day, focusedDay) => _todayBuilder(context, day, focusedDay, periods, next3PeriodDates, user), - selectedBuilder: (context, day, focusedDay) => _selectedBuilder(context, day, focusedDay, periods, next3PeriodDates, user), + RepaintBoundary( + child: SizedBox( + // height: 420, // probably don't need fixed height + child: TableCalendar( + headerStyle: HeaderStyle(formatButtonVisible: false), + startingDayOfWeek: StartingDayOfWeek.monday, + firstDay: kFirstCalendarDay, + lastDay: kLastCalendarDay, + focusedDay: _focusedDay, + availableGestures: AvailableGestures.horizontalSwipe, + selectedDayPredicate: (day) => isSameDay(_selectedDay, day), + rangeStartDay: _rangeStart, + rangeEndDay: _rangeEnd, + calendarFormat: CalendarFormat.month, + rangeSelectionMode: RangeSelectionMode.toggledOff, + onDaySelected: (selectedDay, focusedDay) { + setState(() { + if (!isSameDay(_selectedDay, selectedDay)) { + _selectedDay = selectedDay; + _focusedDay = focusedDay; + _rangeStart = null; + } + }); + }, + daysOfWeekHeight: kTableCalendarDaysOfTheWeekHeight, + daysOfWeekStyle: DaysOfWeekStyle( + weekdayStyle: TextStyle( + color: Theme.of(context).colorScheme.tertiary, + ), + weekendStyle: TextStyle( + color: Theme.of(context).colorScheme.tertiary, + ), + ), + calendarStyle: CalendarStyle(outsideDaysVisible: false), + calendarBuilders: CalendarBuilders( + defaultBuilder: (context, day, focusedDay) => + _defaultBuilder( + context, + day, + focusedDay, + calendarDayLookup, + ), + todayBuilder: (context, day, focusedDay) => _todayBuilder( + context, + day, + focusedDay, + calendarDayLookup, + ), + selectedBuilder: (context, day, focusedDay) => + _selectedBuilder( + context, + day, + focusedDay, + calendarDayLookup, + ), + ), ), ), ), Center( - child: Padding(padding: const EdgeInsets.all(16.0), child: periodProvider.getDataForDate(_selectedDay, context)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: periodProvider.getDataForDate(_selectedDay, context), + ), ), SizedBox(height: 80), // to avoid FAB overlapping content ], @@ -306,42 +415,126 @@ class _HomePageState extends State with TickerProviderStateMixin { ), floatingActionButton: FloatingActionButton( onPressed: () { - final Period? selectedPeriod = PeriodService.getPeriodInDate(_selectedDay, periods); final bool isEditing = selectedPeriod != null; if (isEditing) { - context.go('/log?isEditing=$isEditing&periodId=${selectedPeriod.id}&focusedDay=${Uri.encodeComponent(_selectedDay.toIso8601String())}'); + context.go( + '/log?isEditing=$isEditing&periodId=${selectedPeriod.id}&focusedDay=${Uri.encodeComponent(_selectedDay.toIso8601String())}', + ); return; } - context.go('/log?isEditing=false&focusedDay=${Uri.encodeComponent(_selectedDay.toIso8601String())}'); + context.go( + '/log?isEditing=false&focusedDay=${Uri.encodeComponent(_selectedDay.toIso8601String())}', + ); }, backgroundColor: Theme.of(context).colorScheme.primary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(99.0)), - child: PeriodService.getPeriodInDate(_selectedDay, periods) != null - ? Icon(Icons.edit_rounded, color: Theme.of(context).colorScheme.onPrimary) - : Icon(Icons.add_rounded, color: Theme.of(context).colorScheme.onPrimary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(99.0), + ), + child: selectedPeriod != null + ? Icon( + Icons.edit_rounded, + color: Theme.of(context).colorScheme.onPrimary, + ) + : Icon( + Icons.add_rounded, + color: Theme.of(context).colorScheme.onPrimary, + ), ), ); } - Widget _defaultBuilder(BuildContext context, DateTime day, DateTime focusedDay, periods, List next3PeriodDates, User? user) { + _CalendarDayLookup _buildCalendarDayLookup( + List periods, + List next3PeriodDates, + int periodDuration, + ) { + final Map loggedDays = {}; + final Map upcomingDays = {}; + + for (final Period period in periods) { + final DateTime? endDate = period.endDate; + if (endDate == null) continue; + + DateTime current = DateTimeHelper.stripTime(period.startDate); + final DateTime end = DateTimeHelper.stripTime(endDate); + while (!current.isAfter(end)) { + final bool isStartDay = DateTimeHelper.isSameDay( + current, + period.startDate, + ); + final bool isEndDay = DateTimeHelper.isSameDay(current, endDate); + loggedDays[_dayKey(current)] = _LoggedPeriodDay( + period: period, + isStartDay: isStartDay, + isEndDay: isEndDay, + spansMultipleMonths: + !(isStartDay && isEndDay) && + (DateTimeHelper.isFirstDayOfMonth(current) || + DateTimeHelper.isLastDayOfMonth(current)) && + period.startDate.month != endDate.month, + ); + current = current.add(const Duration(days: 1)); + } + } + + for (final DateTime periodDate in next3PeriodDates) { + final DateTime periodStart = DateTimeHelper.stripTime(periodDate); + final DateTime periodEnd = DateTimeHelper.stripTime( + periodDate.add(Duration(days: periodDuration)), + ); + DateTime current = periodStart; + while (!current.isAfter(periodEnd)) { + final bool isStartDay = DateTimeHelper.isSameDay(periodStart, current); + final bool isEndDay = DateTimeHelper.isSameDay(periodEnd, current); + upcomingDays[_dayKey(current)] = _UpcomingPeriodDay( + isStartDay: isStartDay, + isEndDay: isEndDay, + spansMultipleMonths: + (DateTimeHelper.isFirstDayOfMonth(current) || + DateTimeHelper.isLastDayOfMonth(current)) && + periodStart.month != periodEnd.month, + ); + current = current.add(const Duration(days: 1)); + } + } + + return _CalendarDayLookup( + loggedDays: loggedDays, + upcomingDays: upcomingDays, + ); + } + + static int _dayKey(DateTime date) { + final DateTime strippedDate = DateTimeHelper.stripTime(date); + return strippedDate.year * 10000 + + strippedDate.month * 100 + + strippedDate.day; + } + + Widget _defaultBuilder( + BuildContext context, + DateTime day, + DateTime focusedDay, + _CalendarDayLookup calendarDayLookup, + ) { // distinguish 3 cases: // - selected date is inside logged period // - selected date is inside upcoming period // - default builder for all other dates - Period? period = PeriodService.getPeriodInDate(day, periods); - final isInPeriod = period != null; - final isStartDay = PeriodService.isStartDay(day, periods); - final isEndDay = PeriodService.isEndDay(day, periods); + final _LoggedPeriodDay? loggedPeriodDay = + calendarDayLookup.loggedDays[_dayKey(day)]; + final _UpcomingPeriodDay? upcomingPeriodDay = + calendarDayLookup.upcomingDays[_dayKey(day)]; + final isInPeriod = loggedPeriodDay != null; + final isStartDay = loggedPeriodDay?.isStartDay ?? false; + final isEndDay = loggedPeriodDay?.isEndDay ?? false; final bool isFirstDayOfMonth = DateTimeHelper.isFirstDayOfMonth(day); final bool isLastDayOfMonth = DateTimeHelper.isLastDayOfMonth(day); final bool spansMultipleMonths = - isStartDay && - isEndDay // if this is true gradient is applied - ? false // period lasts 1 single day - should never happen - : (isFirstDayOfMonth || isLastDayOfMonth) && period != null && period.startDate.month != period.endDate!.month; + loggedPeriodDay?.spansMultipleMonths ?? false; BoxDecoration? decoration; Color? textColor; @@ -367,37 +560,31 @@ class _HomePageState extends State with TickerProviderStateMixin { ), ); } - int periodDuration = kDefaultPeriodLength - 1; - if (user != null) { - periodDuration = user.periodLength - 1; - } // next period dates (3) styling - DateTime current = DateTimeHelper.stripTime(day); Color primaryColor = Theme.of(context).colorScheme.primary; - for (int periodIndex = 0; periodIndex < next3PeriodDates.length; periodIndex++) { - DateTime periodStart = DateTimeHelper.stripTime(next3PeriodDates[periodIndex]); - DateTime periodEnd = DateTimeHelper.stripTime(next3PeriodDates[periodIndex].add(Duration(days: periodDuration))); - - if (DateTimeHelper.dayBetweenDates(current, periodStart, periodEnd)) { - // day is inside one of the upcoming periods - final isStartDay = DateTimeHelper.isSameDay(periodStart, day); - final isEndDay = DateTimeHelper.isSameDay(periodEnd, day); - - decoration = BoxDecoration( - border: Border( - left: isStartDay ? BorderSide(color: primaryColor, width: 2.0) : BorderSide.none, - right: isEndDay ? BorderSide(color: primaryColor, width: 2.0) : BorderSide.none, - top: BorderSide(color: primaryColor, width: 2.0), - bottom: BorderSide(color: primaryColor, width: 2.0), - ), - borderRadius: BorderRadius.horizontal( - left: isStartDay ? const Radius.circular(99) : Radius.zero, - right: isEndDay ? const Radius.circular(99) : Radius.zero, - ), - ); - } + if (upcomingPeriodDay != null) { + decoration = BoxDecoration( + border: Border( + left: upcomingPeriodDay.isStartDay + ? BorderSide(color: primaryColor, width: 2.0) + : BorderSide.none, + right: upcomingPeriodDay.isEndDay + ? BorderSide(color: primaryColor, width: 2.0) + : BorderSide.none, + top: BorderSide(color: primaryColor, width: 2.0), + bottom: BorderSide(color: primaryColor, width: 2.0), + ), + borderRadius: BorderRadius.horizontal( + left: upcomingPeriodDay.isStartDay + ? const Radius.circular(99) + : Radius.zero, + right: upcomingPeriodDay.isEndDay + ? const Radius.circular(99) + : Radius.zero, + ), + ); } // default builder @@ -410,42 +597,38 @@ class _HomePageState extends State with TickerProviderStateMixin { ); } - Widget _todayBuilder(BuildContext context, DateTime day, DateTime focusedDay, periods, List next3PeriodDates, User? user) { + Widget _todayBuilder( + BuildContext context, + DateTime day, + DateTime focusedDay, + _CalendarDayLookup calendarDayLookup, + ) { // distinguish 3 cases: // - selected date is inside logged period // - selected date is inside upcoming period // - default builder for all other dates - final Period? period = PeriodService.getPeriodInDate(day, periods); - final isInPeriod = period != null; - int periodDuration = kDefaultPeriodLength - 1; - if (user != null) { - periodDuration = user.periodLength - 1; - } + final bool isInPeriod = calendarDayLookup.loggedDays.containsKey( + _dayKey(day), + ); + final bool isInUpcomingPeriod = calendarDayLookup.upcomingDays.containsKey( + _dayKey(day), + ); - if (isInPeriod) { + if (isInPeriod || isInUpcomingPeriod) { // today is inside logged period - return _defaultBuilder(context, day, focusedDay, periods, next3PeriodDates, user); + return _defaultBuilder(context, day, focusedDay, calendarDayLookup); } // default styling that is returned if today is just a regular day, meaning it doesn't fall into any of the logged or upcoming periods BoxDecoration? decoration = BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: Theme.of(context).colorScheme.secondary, width: 2), + border: Border.all( + color: Theme.of(context).colorScheme.secondary, + width: 2, + ), ); Color? textColor = Theme.of(context).colorScheme.onSurface; - // Check if today is in any of the upcoming periods - DateTime current = DateTimeHelper.stripTime(day); - for (DateTime periodDate in next3PeriodDates) { - DateTime periodStart = DateTimeHelper.stripTime(periodDate); - DateTime periodEnd = DateTimeHelper.stripTime(periodDate.add(Duration(days: periodDuration))); - - if (DateTimeHelper.dayBetweenDates(current, periodStart, periodEnd)) { - // today is inside one of the upcoming periods - return _defaultBuilder(context, day, focusedDay, periods, next3PeriodDates, user); - } - } - return Container( margin: const EdgeInsets.fromLTRB(0, 4, 0, 4), decoration: decoration, @@ -455,50 +638,33 @@ class _HomePageState extends State with TickerProviderStateMixin { ); } - Widget _selectedBuilder(BuildContext context, DateTime day, DateTime focusedDay, periods, List next3PeriodDates, User? user) { + Widget _selectedBuilder( + BuildContext context, + DateTime day, + DateTime focusedDay, + _CalendarDayLookup calendarDayLookup, + ) { // distinguish 3 cases: // - selected date is inside logged period // - selected date is inside upcoming period // - default builder for all other dates - final Period? period = PeriodService.getPeriodInDate(day, periods); - final isInPeriod = period != null; - final isStartDay = PeriodService.isStartDay(day, periods); - final isEndDay = PeriodService.isEndDay(day, periods); + final _LoggedPeriodDay? loggedPeriodDay = + calendarDayLookup.loggedDays[_dayKey(day)]; + final _UpcomingPeriodDay? upcomingPeriodDay = + calendarDayLookup.upcomingDays[_dayKey(day)]; + final isInPeriod = loggedPeriodDay != null; + final isStartDay = loggedPeriodDay?.isStartDay ?? false; + final isEndDay = loggedPeriodDay?.isEndDay ?? false; final bool isFirstDayOfMonth = DateTimeHelper.isFirstDayOfMonth(day); final bool isLastDayOfMonth = DateTimeHelper.isLastDayOfMonth(day); final bool spansMultipleMonths = - isStartDay && - isEndDay // if this is true gradient is applied - ? false // period lasts 1 single day - should never happen - : (isFirstDayOfMonth || isLastDayOfMonth) && period != null && period.startDate.month != period.endDate!.month; - - bool insideUpcomingPeriod = false; - bool isNextPeriodStartDay = false; - bool isNextPeriodEndDay = false; - bool upComingSpanMultipleMonths = false; - int periodDuration = kDefaultPeriodLength - 1; - if (user != null) { - periodDuration = user.periodLength - 1; - } - - // Check if selected day is in any of the upcoming periods - DateTime current = DateTimeHelper.stripTime(day); - for (DateTime periodDate in next3PeriodDates) { - DateTime periodStart = DateTimeHelper.stripTime(periodDate); - DateTime periodEnd = DateTimeHelper.stripTime(periodDate.add(Duration(days: periodDuration))); - - if (DateTimeHelper.dayBetweenDates(current, periodStart, periodEnd)) { - isNextPeriodStartDay = DateTimeHelper.isSameDay(periodStart, day); - isNextPeriodEndDay = DateTimeHelper.isSameDay(periodEnd, day); - insideUpcomingPeriod = true; - - // Check if this upcoming period spans multiple months - upComingSpanMultipleMonths = (isFirstDayOfMonth || isLastDayOfMonth) && periodStart.month != periodEnd.month; - - break; // Only consider the first matching period - } - } + loggedPeriodDay?.spansMultipleMonths ?? false; + final bool insideUpcomingPeriod = upcomingPeriodDay != null; + final bool isNextPeriodStartDay = upcomingPeriodDay?.isStartDay ?? false; + final bool isNextPeriodEndDay = upcomingPeriodDay?.isEndDay ?? false; + final bool upComingSpanMultipleMonths = + upcomingPeriodDay?.spansMultipleMonths ?? false; BoxDecoration? decoration; Color? textColor; @@ -544,7 +710,10 @@ class _HomePageState extends State with TickerProviderStateMixin { textColor = Theme.of(context).colorScheme.surface; } else { // default selector builder - decoration = BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle); + decoration = BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ); textColor = Theme.of(context).colorScheme.onPrimary; } @@ -557,3 +726,39 @@ class _HomePageState extends State with TickerProviderStateMixin { ); } } + +class _CalendarDayLookup { + final Map loggedDays; + final Map upcomingDays; + + const _CalendarDayLookup({ + required this.loggedDays, + required this.upcomingDays, + }); +} + +class _LoggedPeriodDay { + final Period period; + final bool isStartDay; + final bool isEndDay; + final bool spansMultipleMonths; + + const _LoggedPeriodDay({ + required this.period, + required this.isStartDay, + required this.isEndDay, + required this.spansMultipleMonths, + }); +} + +class _UpcomingPeriodDay { + final bool isStartDay; + final bool isEndDay; + final bool spansMultipleMonths; + + const _UpcomingPeriodDay({ + required this.isStartDay, + required this.isEndDay, + required this.spansMultipleMonths, + }); +} diff --git a/lib/providers/period_provider.dart b/lib/providers/period_provider.dart index 2ba2960..5afaea0 100644 --- a/lib/providers/period_provider.dart +++ b/lib/providers/period_provider.dart @@ -25,6 +25,7 @@ class PeriodProvider extends ChangeNotifier { Future insertPeriod(Period period) async { await _db.insertPeriod(period); _periods.add(period); + _periods.sort((a, b) => b.startDate.compareTo(a.startDate)); notifyListeners(); } @@ -43,32 +44,44 @@ class PeriodProvider extends ChangeNotifier { /// @param dynamicPeriodPrediction Whether to use dynamic prediction based on average cycle length /// @param userCycleLength The user's set cycle length (used if dynamic prediction is false) /// @returns The predicted next period start date, or null if not enough data - DateTime? getNextPeriodDate(bool dynamicPeriodPrediction, int? userCycleLength) { - if (periods.isEmpty) return null; - if (periods.length < 2) { + DateTime? getNextPeriodDate( + bool dynamicPeriodPrediction, + int? userCycleLength, + ) { + if (_periods.isEmpty) return null; + final List sortedPeriods = List.from(_periods) + ..sort((a, b) => a.startDate.compareTo(b.startDate)); + if (sortedPeriods.length < 2) { // Not enough data to predict next period - only one period logged // Use the provided userCycleLength // Case when user gets not enough data to predict the next period right after onboarding - return periods.last.startDate.add(Duration(days: userCycleLength ?? kDefaultCycleLength)); + return sortedPeriods.last.startDate.add( + Duration(days: userCycleLength ?? kDefaultCycleLength), + ); } - periods.sort((a, b) => a.startDate.compareTo(b.startDate)); if (dynamicPeriodPrediction) { // dynamic prediction based on average cycle length final avgCycle = getAverageCycleLength(useRecent6: true); if (avgCycle == null) return null; - return periods.last.startDate.add(Duration(days: avgCycle.round())); + return sortedPeriods.last.startDate.add(Duration(days: avgCycle.round())); } else { // static prediction based on last period and user's cycle length if (userCycleLength == null) return null; - return periods.last.startDate.add(Duration(days: userCycleLength)); + return sortedPeriods.last.startDate.add(Duration(days: userCycleLength)); } } // Returns the next 3 expected period start dates - List getNext3PeriodDates(bool dynamicPredictionMode, int? userCycleLength) { + List getNext3PeriodDates( + bool dynamicPredictionMode, + int? userCycleLength, + ) { final List upcomingPeriods = []; - final DateTime? firstPeriod = getNextPeriodDate(dynamicPredictionMode, userCycleLength); + final DateTime? firstPeriod = getNextPeriodDate( + dynamicPredictionMode, + userCycleLength, + ); if (firstPeriod == null) return upcomingPeriods; upcomingPeriods.add(firstPeriod); @@ -97,12 +110,19 @@ class PeriodProvider extends ChangeNotifier { date ??= DateTime.now(); final targetDate = DateTime.utc(date.year, date.month, date.day); - _periods.sort((a, b) => a.startDate.compareTo(b.startDate)); + final List sortedPeriods = List.from(_periods) + ..sort((a, b) => a.startDate.compareTo(b.startDate)); // Normalize period dates to UTC date only for consistent comparison - final normalizedPeriods = _periods.map((p) { - final DateTime startDate = DateTime.utc(p.startDate.year, p.startDate.month, p.startDate.day); - final DateTime? endDate = p.endDate != null ? DateTime.utc(p.endDate!.year, p.endDate!.month, p.endDate!.day) : null; + final normalizedPeriods = sortedPeriods.map((p) { + final DateTime startDate = DateTime.utc( + p.startDate.year, + p.startDate.month, + p.startDate.day, + ); + final DateTime? endDate = p.endDate != null + ? DateTime.utc(p.endDate!.year, p.endDate!.month, p.endDate!.day) + : null; return {'period': p, 'start': startDate, 'end': endDate}; }).toList(); @@ -115,7 +135,8 @@ class PeriodProvider extends ChangeNotifier { Map? lastPeriodData; for (var periodData in normalizedPeriods) { final startDate = periodData['start'] as DateTime; - if (targetDate.isAtSameMomentAs(startDate) || targetDate.isAfter(startDate)) { + if (targetDate.isAtSameMomentAs(startDate) || + targetDate.isAfter(startDate)) { lastPeriodData = periodData; } else { break; @@ -134,28 +155,39 @@ class PeriodProvider extends ChangeNotifier { } // Returns a status message (e.g., late, on track) - PeriodStatusMessage getStatusMessage(Color defaultColor, DateTime? nextPeriodDate) { - PeriodStatusMessage status = PeriodStatusMessage(text: '', color: defaultColor); + PeriodStatusMessage getStatusMessage( + Color defaultColor, + DateTime? nextPeriodDate, + ) { + PeriodStatusMessage status = PeriodStatusMessage( + text: '', + color: defaultColor, + ); if (_periods.isEmpty || nextPeriodDate == null) { // in this case status bar on home page is hidden - status.text = 'Start by tapping the + button below to log your most recent period'; + status.text = + 'Start by tapping the + button below to log your most recent period'; return status; } status.color = Colors.green; - final List sortedPeriods = List.from(_periods)..sort((a, b) => a.startDate.compareTo(b.startDate)); + final List sortedPeriods = List.from(_periods) + ..sort((a, b) => a.startDate.compareTo(b.startDate)); - final lastPeriod = sortedPeriods.last; // last period dates are normalized to UTC already + final lastPeriod = + sortedPeriods.last; // last period dates are normalized to UTC already // ensure both nextPeriodDate and today are stripped of time and in UTC final DateTime now = DateTime.now(); final DateTime today = DateTime.utc(now.year, now.month, now.day); // Check if currently in period - if ((today.isAtSameMomentAs(lastPeriod.startDate) || today.isAtSameMomentAs(lastPeriod.endDate!)) || - (today.isAfter(lastPeriod.startDate) && today.isBefore(lastPeriod.endDate!))) { + if ((today.isAtSameMomentAs(lastPeriod.startDate) || + today.isAtSameMomentAs(lastPeriod.endDate!)) || + (today.isAfter(lastPeriod.startDate) && + today.isBefore(lastPeriod.endDate!))) { status.text = 'Currently in period'; return status; } @@ -167,7 +199,8 @@ class PeriodProvider extends ChangeNotifier { // Returns average period length in days double? getAveragePeriodLength({bool? useRecent6}) { // Only consider periods with both start and end dates - final completed = _periods.where((p) => p.endDate != null).toList(); + final completed = _periods.where((p) => p.endDate != null).toList() + ..sort((a, b) => a.startDate.compareTo(b.startDate)); if (completed.isEmpty) return null; List periodsToUse = completed; @@ -175,7 +208,10 @@ class PeriodProvider extends ChangeNotifier { periodsToUse = completed.sublist(completed.length - 6); } - final lengths = periodsToUse.map((p) => p.endDate!.difference(p.startDate).inDays + 1).where((days) => days > 0).toList(); + final lengths = periodsToUse + .map((p) => p.endDate!.difference(p.startDate).inDays + 1) + .where((days) => days > 0) + .toList(); if (lengths.isEmpty) return null; return lengths.reduce((a, b) => a + b) / lengths.length; } @@ -188,13 +224,16 @@ class PeriodProvider extends ChangeNotifier { // returns null if userCycleLength is null return userCycleLength?.toDouble(); } - final List sorted = List.from(periods)..sort((a, b) => a.startDate.compareTo(b.startDate)); + final List sorted = List.from(periods) + ..sort((a, b) => a.startDate.compareTo(b.startDate)); final List cycles = []; if (useRecent6 == true && sorted.length > 6) { sorted.removeRange(0, sorted.length - 6); } for (int i = 1; i < sorted.length; i++) { - cycles.add(sorted[i].startDate.difference(sorted[i - 1].startDate).inDays); + cycles.add( + sorted[i].startDate.difference(sorted[i - 1].startDate).inDays, + ); } if (cycles.isEmpty) return null; return cycles.reduce((a, b) => a + b) / cycles.length; @@ -218,7 +257,10 @@ class PeriodProvider extends ChangeNotifier { final Period? period = PeriodService.getPeriodInDate(checkDate, periods); if (period == null) { - return Text('Cycle Day: $cycleDay', style: Theme.of(context).textTheme.bodyMedium); + return Text( + 'Cycle Day: $cycleDay', + style: Theme.of(context).textTheme.bodyMedium, + ); } final String? notes; @@ -231,7 +273,10 @@ class PeriodProvider extends ChangeNotifier { return Center( child: Column( children: [ - Text('Cycle Day: $cycleDay', style: Theme.of(context).textTheme.bodyMedium), + Text( + 'Cycle Day: $cycleDay', + style: Theme.of(context).textTheme.bodyMedium, + ), SizedBox(height: 4), Text( 'Selected period: ${DateTimeHelper.displayDate(period.startDate)} - ' diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 5f6b2ac..9377137 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -54,9 +54,10 @@ class NotificationService { tz.setLocalLocation(tz.getLocation('Europe/Ljubljana')); // TODO const AndroidInitializationSettings androidInit = AndroidInitializationSettings('@drawable/ic_stat_notify'); + const DarwinInitializationSettings iosInit = DarwinInitializationSettings(); await _flutterLocalNotificationsPlugin.initialize( - InitializationSettings(android: androidInit), + InitializationSettings(android: androidInit, iOS: iosInit), // onDidReceiveNotificationResponse: (NotificationResponse response) { // if (response.actionId == 'log') { // navigatorKey.currentState?.pushNamed('/log'); @@ -68,9 +69,15 @@ class NotificationService { ); } - /// Requests notification permissions (Android 13+). + /// Requests notification permissions. /// @return true if permissions are granted, false otherwise. Future requestPermissions() async { + final iosPlugin = NotificationService()._flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation(); + if (iosPlugin != null) { + final granted = await iosPlugin.requestPermissions(alert: true, badge: true, sound: true); + return granted == true; + } + // Android 13+ final androidPlugin = NotificationService()._flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation(); @@ -103,7 +110,7 @@ class NotificationService { title, body, tz.TZDateTime.from(scheduledDate, tz.local), - NotificationDetails(android: _androidNotificationDetails), + NotificationDetails(android: _androidNotificationDetails, iOS: const DarwinNotificationDetails()), androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, payload: payload, ); diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# 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! + + 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/pubspec.lock b/pubspec.lock index 0675949..03f0b91 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -276,10 +276,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -618,10 +618,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timezone: dependency: "direct main" description: diff --git a/test/widget_test.dart b/test/widget_test.dart index fe4bce0..1bc1fde 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,32 +1,16 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - import 'package:period_tracker/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. + testWidgets('shows onboarding as the initial app route', ( + WidgetTester tester, + ) async { await tester.pumpWidget( - const PeriodTrackerApp(showOnboarding: false, isAfterRestore: false), + const PeriodTrackerApp(showOnboarding: true, isAfterRestore: false), ); + await tester.pumpAndSettle(); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add_rounded)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('Welcome!'), findsOneWidget); + expect(find.text('Restore data'), findsOneWidget); }); }