diff --git a/.vscode/launch.json b/.vscode/launch.json index 2d15047..af48daf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Launch development", + "name": "Debug mode (development flavor)", "request": "launch", "type": "dart", "program": "lib/main.dart", @@ -17,18 +17,17 @@ ] }, { - "name": "Launch staging", + "name": "Release mode (development flavor)", "request": "launch", "type": "dart", "program": "lib/main.dart", - "args": ["--flavor", "staging", "--target", "lib/main.dart"] - }, - { - "name": "Launch production (release)", - "request": "launch", - "type": "dart", - "program": "lib/main.dart", - "args": ["--release", "--flavor", "production", "--target", "lib/main.dart"] + "args": [ + "--release", + "--flavor", + "development", + "--target", + "lib/main.dart" + ] } ] -} +} \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1d6abb4..c9ff353 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,5 +1,5 @@ -import java.util.Properties import java.io.FileInputStream +import java.util.Properties plugins { id("com.android.application") @@ -10,6 +10,7 @@ plugins { val keystoreProperties = Properties() val keystorePropertiesFile = rootProject.file("key.properties") + if (keystorePropertiesFile.exists()) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } @@ -24,9 +25,7 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } + kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() } defaultConfig { applicationId = "dk.cafeanalog.cafe_analog_app" @@ -45,7 +44,6 @@ android { keyAlias = System.getenv("ANDROID_KEYSTORE_ALIAS") keyPassword = System.getenv("ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD") storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") - } else { keyAlias = keystoreProperties["keyAlias"] as String? keyPassword = keystoreProperties["keyPassword"] as String? @@ -60,17 +58,12 @@ android { create("production") { dimension = "default" applicationIdSuffix = "" - manifestPlaceholders["appName"] = "Router Test App" - } - create("staging") { - dimension = "default" - applicationIdSuffix = ".stg" - manifestPlaceholders["appName"] = "[STG] Router Test App" + manifestPlaceholders["appName"] = "Analog" } create("development") { dimension = "default" applicationIdSuffix = ".dev" - manifestPlaceholders["appName"] = "[DEV] Router Test App" + manifestPlaceholders["appName"] = "[DEV] Analog" } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2aa7639..6188c4d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,9 +29,10 @@ - + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..e7efa9e 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,3 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" +#include "Flavors.xcconfig" diff --git a/ios/Flutter/Flavors.xcconfig b/ios/Flutter/Flavors.xcconfig new file mode 100644 index 0000000..4f0dcb2 --- /dev/null +++ b/ios/Flutter/Flavors.xcconfig @@ -0,0 +1,19 @@ +// Flavor-specific settings +// This file is included from Debug.xcconfig and Release.xcconfig +// and uses conditional variable expansion based on FLAVOR + +// Default values (production) +DEEP_LINK_SCHEME = cafeanalog +FLAVOR_APP_NAME = Analog + +// Override for development +DEEP_LINK_SCHEME[config=Debug-development] = cafeanalog-dev +DEEP_LINK_SCHEME[config=Release-development] = cafeanalog-dev +FLAVOR_APP_NAME[config=Debug-development] = [DEV] Analog +FLAVOR_APP_NAME[config=Release-development] = [DEV] Analog + +// Override for production +DEEP_LINK_SCHEME[config=Debug-production] = cafeanalog +DEEP_LINK_SCHEME[config=Release-production] = cafeanalog +FLAVOR_APP_NAME[config=Debug-production] = Analog +FLAVOR_APP_NAME[config=Release-production] = Analog diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..03b4e8c 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,3 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" +#include "Flavors.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..37cd4a7 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,23 @@ +PODS: + - Flutter (1.0.0) + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c951814..a8c8485 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 63948AF0203226AA037B02A7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE33F2789F19B6B8FEB138F3 /* Pods_Runner.framework */; }; + 689D96EFA946DED300D35B69 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9EEE1A362A0C0D30812C4D90 /* Pods_RunnerTests.framework */; }; 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 */; }; @@ -40,14 +42,22 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 03ECF82432B0030D22E8EFBA /* Pods-RunnerTests.release-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release-production.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release-production.xcconfig"; sourceTree = ""; }; + 0FC98E59B2EDE6BAA5641967 /* Pods-RunnerTests.profile-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile-production.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile-production.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 = ""; }; 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; }; + 3774EE6D4F299149ADDD1F12 /* Pods-RunnerTests.debug-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug-production.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug-production.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 48465233F49C55A3C6A940A8 /* Pods-Runner.profile-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-production.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-production.xcconfig"; sourceTree = ""; }; + 5E33BD643C70C7A5C312484D /* Pods-RunnerTests.profile-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile-development.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile-development.xcconfig"; sourceTree = ""; }; + 62F47DACAC15067B81C0398D /* Pods-RunnerTests.release-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release-development.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release-development.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 = ""; }; + 798694C28E76551E89B46CE1 /* Pods-RunnerTests.debug-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug-development.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug-development.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 905101D50D3B9DC8B3AC3A13 /* Pods-Runner.debug-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-development.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-development.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,13 +65,28 @@ 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 = ""; }; + 99BEC39E0A63C94E504944CE /* Pods-Runner.debug-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-production.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-production.xcconfig"; sourceTree = ""; }; + 9EEE1A362A0C0D30812C4D90 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AE33F2789F19B6B8FEB138F3 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C4387A9ACE0701B08D96D92E /* Pods-Runner.release-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-development.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-development.xcconfig"; sourceTree = ""; }; + E94BEC860F4078E9F45629D1 /* Pods-Runner.profile-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-development.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-development.xcconfig"; sourceTree = ""; }; + ED89494CA39A23003B91C1D4 /* Pods-Runner.release-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-production.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-production.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 46C9CE9B67EBEB43359ADDD3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 689D96EFA946DED300D35B69 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 63948AF0203226AA037B02A7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +101,35 @@ path = RunnerTests; sourceTree = ""; }; + 450CF66000B4BCC7D5386198 /* Pods */ = { + isa = PBXGroup; + children = ( + 99BEC39E0A63C94E504944CE /* Pods-Runner.debug-production.xcconfig */, + 905101D50D3B9DC8B3AC3A13 /* Pods-Runner.debug-development.xcconfig */, + ED89494CA39A23003B91C1D4 /* Pods-Runner.release-production.xcconfig */, + C4387A9ACE0701B08D96D92E /* Pods-Runner.release-development.xcconfig */, + 48465233F49C55A3C6A940A8 /* Pods-Runner.profile-production.xcconfig */, + E94BEC860F4078E9F45629D1 /* Pods-Runner.profile-development.xcconfig */, + 3774EE6D4F299149ADDD1F12 /* Pods-RunnerTests.debug-production.xcconfig */, + 798694C28E76551E89B46CE1 /* Pods-RunnerTests.debug-development.xcconfig */, + 03ECF82432B0030D22E8EFBA /* Pods-RunnerTests.release-production.xcconfig */, + 62F47DACAC15067B81C0398D /* Pods-RunnerTests.release-development.xcconfig */, + 0FC98E59B2EDE6BAA5641967 /* Pods-RunnerTests.profile-production.xcconfig */, + 5E33BD643C70C7A5C312484D /* Pods-RunnerTests.profile-development.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 5154D0D635A201910C77961C /* Frameworks */ = { + isa = PBXGroup; + children = ( + AE33F2789F19B6B8FEB138F3 /* Pods_Runner.framework */, + 9EEE1A362A0C0D30812C4D90 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +148,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 450CF66000B4BCC7D5386198 /* Pods */, + 5154D0D635A201910C77961C /* Frameworks */, ); sourceTree = ""; }; @@ -128,8 +184,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + ECD2C73C0DC060BBF91135DE /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 46C9CE9B67EBEB43359ADDD3 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +203,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 1920A2473F2D197698E1E4FF /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 2E1FC85F3C7853B1DEAF0CE9 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -222,6 +282,45 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1920A2473F2D197698E1E4FF /* [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; + }; + 2E1FC85F3C7853B1DEAF0CE9 /* [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; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +352,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + ECD2C73C0DC060BBF91135DE /* [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 */ @@ -367,7 +488,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 87PNV8S7CN; ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "Router Test App"; + FLAVOR_APP_NAME = Analog; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -385,6 +506,7 @@ }; 3C9C551B2B07AC99000E5FCD /* Debug-production */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 3774EE6D4F299149ADDD1F12 /* Pods-RunnerTests.debug-production.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -461,7 +583,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 87PNV8S7CN; ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "Router Test App"; + FLAVOR_APP_NAME = Analog; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -478,6 +600,7 @@ }; 3C9C551E2B07ACA4000E5FCD /* Release-production */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 03ECF82432B0030D22E8EFBA /* Pods-RunnerTests.release-production.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -550,7 +673,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 87PNV8S7CN; ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "Router Test App"; + FLAVOR_APP_NAME = Analog; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -567,6 +690,7 @@ }; 3C9C55242B07ACBD000E5FCD /* Profile-production */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0FC98E59B2EDE6BAA5641967 /* Pods-RunnerTests.profile-production.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -644,7 +768,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 87PNV8S7CN; ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "[DEV] Router Test App"; + FLAVOR_APP_NAME = "[DEV] Analog"; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -662,6 +786,7 @@ }; 3C9C55272B07ACE7000E5FCD /* Debug-development */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 798694C28E76551E89B46CE1 /* Pods-RunnerTests.debug-development.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -736,7 +861,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 87PNV8S7CN; ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "[DEV] Router Test App"; + FLAVOR_APP_NAME = "[DEV] Analog"; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -753,6 +878,7 @@ }; 3C9C552D2B07AD09000E5FCD /* Profile-development */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5E33BD643C70C7A5C312484D /* Pods-RunnerTests.profile-development.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -827,7 +953,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 87PNV8S7CN; ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "[DEV] Router Test App"; + FLAVOR_APP_NAME = "[DEV] Analog"; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -844,6 +970,7 @@ }; 3C9C55302B07AD1B000E5FCD /* Release-development */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 62F47DACAC15067B81C0398D /* Pods-RunnerTests.release-development.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -857,283 +984,6 @@ }; name = "Release-development"; }; - 3C9C55312B07AD26000E5FCD /* Debug-staging */ = { - 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 = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = "Debug-staging"; - }; - 3C9C55322B07AD26000E5FCD /* Debug-staging */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stg"; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 87PNV8S7CN; - ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "[STG] Router Test App"; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PACKAGE_CONFIG = .dart_tool/package_config.json; - PRODUCT_BUNDLE_IDENTIFIER = "com.example.verygoodcore.router-test-app.stg"; - 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-staging"; - }; - 3C9C55332B07AD26000E5FCD /* Debug-staging */ = { - isa = XCBuildConfiguration; - 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.verygoodcore.router-test-app.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-staging"; - }; - 3C9C55342B07AD41000E5FCD /* Release-staging */ = { - 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 = 13.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-staging"; - }; - 3C9C55352B07AD41000E5FCD /* Release-staging */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stg"; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 87PNV8S7CN; - ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "[STG] Router Test App"; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PACKAGE_CONFIG = .dart_tool/package_config.json; - PRODUCT_BUNDLE_IDENTIFIER = "com.example.verygoodcore.router-test-app.stg"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Release-staging"; - }; - 3C9C55362B07AD41000E5FCD /* Release-staging */ = { - isa = XCBuildConfiguration; - 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.verygoodcore.router-test-app.RunnerTests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = "Release-staging"; - }; - 3C9C55372B07AD4B000E5FCD /* Profile-staging */ = { - 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 = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = "Profile-staging"; - }; - 3C9C55382B07AD4B000E5FCD /* Profile-staging */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stg"; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 87PNV8S7CN; - ENABLE_BITCODE = NO; - FLAVOR_APP_NAME = "[STG] Router Test App"; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PACKAGE_CONFIG = .dart_tool/package_config.json; - PRODUCT_BUNDLE_IDENTIFIER = "com.example.verygoodcore.router-test-app.stg"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = "Profile-staging"; - }; - 3C9C55392B07AD4B000E5FCD /* Profile-staging */ = { - isa = XCBuildConfiguration; - 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.verygoodcore.router-test-app.RunnerTests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = "Profile-staging"; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1141,13 +991,10 @@ isa = XCConfigurationList; buildConfigurations = ( 3C9C551B2B07AC99000E5FCD /* Debug-production */, - 3C9C55332B07AD26000E5FCD /* Debug-staging */, 3C9C55272B07ACE7000E5FCD /* Debug-development */, 3C9C551E2B07ACA4000E5FCD /* Release-production */, - 3C9C55362B07AD41000E5FCD /* Release-staging */, 3C9C55302B07AD1B000E5FCD /* Release-development */, 3C9C55242B07ACBD000E5FCD /* Profile-production */, - 3C9C55392B07AD4B000E5FCD /* Profile-staging */, 3C9C552D2B07AD09000E5FCD /* Profile-development */, ); defaultConfigurationIsVisible = 0; @@ -1157,13 +1004,10 @@ isa = XCConfigurationList; buildConfigurations = ( 3C9C55192B07AC99000E5FCD /* Debug-production */, - 3C9C55312B07AD26000E5FCD /* Debug-staging */, 3C9C55252B07ACE7000E5FCD /* Debug-development */, 3C9C551C2B07ACA4000E5FCD /* Release-production */, - 3C9C55342B07AD41000E5FCD /* Release-staging */, 3C9C552E2B07AD1B000E5FCD /* Release-development */, 3C9C55222B07ACBD000E5FCD /* Profile-production */, - 3C9C55372B07AD4B000E5FCD /* Profile-staging */, 3C9C552B2B07AD09000E5FCD /* Profile-development */, ); defaultConfigurationIsVisible = 0; @@ -1173,13 +1017,10 @@ isa = XCConfigurationList; buildConfigurations = ( 3C9C551A2B07AC99000E5FCD /* Debug-production */, - 3C9C55322B07AD26000E5FCD /* Debug-staging */, 3C9C55262B07ACE7000E5FCD /* Debug-development */, 3C9C551D2B07ACA4000E5FCD /* Release-production */, - 3C9C55352B07AD41000E5FCD /* Release-staging */, 3C9C552F2B07AD1B000E5FCD /* Release-development */, 3C9C55232B07ACBD000E5FCD /* Profile-production */, - 3C9C55382B07AD4B000E5FCD /* Profile-staging */, 3C9C552C2B07AD09000E5FCD /* Profile-development */, ); defaultConfigurationIsVisible = 0; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/staging.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/staging.xcscheme deleted file mode 100644 index 6e3b252..0000000 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/staging.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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/ios/Runner/Info.plist b/ios/Runner/Info.plist index 25236cf..968b30f 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,6 +7,19 @@ en es + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + dk.cafeanalog + CFBundleURLSchemes + + $(DEEP_LINK_SCHEME) + + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/lib/app.dart b/lib/app.dart deleted file mode 100644 index 35e4635..0000000 --- a/lib/app.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:cafe_analog_app/login/login_screen.dart'; -import 'package:cafe_analog_app/login/secret_page.dart'; -import 'package:cafe_analog_app/receipts/receipts_screen.dart'; -import 'package:cafe_analog_app/redeem_voucher/redeem_voucher_screen.dart'; -import 'package:cafe_analog_app/settings/settings_screen.dart'; -import 'package:cafe_analog_app/settings/your_profile_screen.dart'; -import 'package:cafe_analog_app/stats/view/stats_screen.dart'; -import 'package:cafe_analog_app/tickets/buy_tickets/buy_tickets_screen.dart'; -import 'package:cafe_analog_app/tickets/buy_tickets/product.dart'; -import 'package:cafe_analog_app/tickets/buy_tickets/ticket_detail_screen.dart'; -import 'package:cafe_analog_app/tickets/my_tickets/tickets_screen.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -final goRouter = GoRouter( - initialLocation: '/tickets', - debugLogDiagnostics: true, - routes: [ - GoRoute( - path: '/login', - pageBuilder: (context, state) => const MaterialPage(child: LoginScreen()), - ), - GoRoute( - path: '/verify-mobilepay/:id', - pageBuilder: (_, state) => MaterialPage( - child: SecretScreen(id: state.pathParameters['id']!), - ), - ), - StatefulShellRoute.indexedStack( - builder: (context, state, navigationShell) { - return ScaffoldWithNestedNavigation(navigationShell: navigationShell); - }, - branches: [ - StatefulShellBranch( - routes: [ - GoRoute( - path: '/tickets', - pageBuilder: (context, state) => const NoTransitionPage( - child: TicketsScreen(), - ), - routes: [ - GoRoute( - path: 'buy', - builder: (_, _) => const BuyTicketsScreen(), - routes: [ - GoRoute( - path: 'ticket/:id', - pageBuilder: (context, state) { - // we don't use id here, but in a real app you might - // fetch the product details based on the id - // we pass the whole product via extra for simplicity - // - // cast state.extra to Product - final product = state.extra! as Product; - return MaterialPage( - fullscreenDialog: true, - child: TicketDetailScreen(product: product), - ); - }, - ), - ], - ), - GoRoute( - path: 'redeem_voucher', - pageBuilder: (context, state) => const MaterialPage( - child: RedeemVoucherScreen(), - ), - ), - ], - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/receipts', - pageBuilder: (context, state) => const NoTransitionPage( - child: ReceiptsScreen(), - ), - routes: [ - GoRoute( - path: 'purchase_receipt/:id', - builder: (context, state) => - Container(), // TODO(marfavi): Implement receipt screen - ), - GoRoute( - path: 'swipe_receipt/:id', - builder: (context, state) => - Container(), // TODO(marfavi): Implement receipt screen - ), - ], - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/stats', - pageBuilder: (context, state) => - const NoTransitionPage(child: StatsScreen()), - ), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/settings', - pageBuilder: (context, state) => - const NoTransitionPage(child: SettingsScreen()), - routes: [ - GoRoute( - path: 'your-profile', - pageBuilder: (context, state) => - const MaterialPage(child: YourProfileScreen()), - ), - ], - ), - ], - ), - ], - ), - ], -); - -class App extends StatefulWidget { - const App({super.key}); - - @override - State createState() => _AppState(); -} - -class _AppState extends State { - // TODO(marfavi): Remove theme switching when all widgets support system theme - Brightness _brightness = Brightness.light; - - void _setBrightness(Brightness brightness) { - setState(() { - _brightness = brightness; - }); - } - - @override - Widget build(BuildContext context) { - return MaterialApp.router( - routerConfig: goRouter, - theme: ThemeData( - brightness: _brightness, - colorScheme: ColorScheme.fromSeed( - brightness: _brightness, - // brightness: Brightness.dark, - // dynamicSchemeVariant: DynamicSchemeVariant.vibrant, - seedColor: const Color(0xFF785B38), - // seedColor: const Color(0xFF362619), // GOOD - // primary: const Color(0xFF785B38), - // surface: const Color(0xFFF9F8F2), - // surfaceContainer: const Color(0xFFF0E8D8), - // secondaryContainer: const Color(0xFFE9D4B7), - ), - ), - builder: (context, child) { - return AppBrightnessProvider( - onBrightnessChanged: _setBrightness, - child: child!, - ); - }, - ); - } -} - -class AppBrightnessProvider extends InheritedWidget { - const AppBrightnessProvider({ - required this.onBrightnessChanged, - required super.child, - super.key, - }); - - final void Function(Brightness) onBrightnessChanged; - - static AppBrightnessProvider of(BuildContext context) { - final result = context - .dependOnInheritedWidgetOfExactType(); - assert(result != null, 'No AppBrightnessProvider found in context'); - return result!; - } - - @override - bool updateShouldNotify(AppBrightnessProvider oldWidget) { - return onBrightnessChanged != oldWidget.onBrightnessChanged; - } -} - -class ScaffoldWithNestedNavigation extends StatelessWidget { - const ScaffoldWithNestedNavigation({ - required this.navigationShell, - super.key, - }); - - final StatefulNavigationShell navigationShell; - - void _goBranch(int index) { - navigationShell.goBranch( - index, - // A common pattern when using bottom navigation bars is to support - // navigating to the initial location when tapping the item that is - // already active. This example demonstrates how to support this behavior, - // using the initialLocation parameter of goBranch. - initialLocation: index == navigationShell.currentIndex, - ); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - return ScaffoldWithNavigationBar( - body: navigationShell, - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: _goBranch, - ); - }, - ); - } -} - -class ScaffoldWithNavigationBar extends StatelessWidget { - const ScaffoldWithNavigationBar({ - required this.body, - required this.selectedIndex, - required this.onDestinationSelected, - super.key, - }); - final Widget body; - final int selectedIndex; - final ValueChanged onDestinationSelected; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: body, - bottomNavigationBar: NavigationBar( - selectedIndex: selectedIndex, - destinations: [ - NavigationDestination( - label: 'Tickets', - icon: selectedIndex == 0 - ? const Icon(Icons.confirmation_num) - : const Icon(Icons.confirmation_num_outlined), - ), - NavigationDestination( - label: 'Receipts', - icon: selectedIndex == 1 - ? const Icon(Icons.receipt) - : const Icon(Icons.receipt_outlined), - ), - NavigationDestination( - label: 'Stats', - icon: selectedIndex == 2 - ? const Icon(Icons.leaderboard) - : const Icon(Icons.leaderboard_outlined), - ), - NavigationDestination( - label: 'Settings', - icon: selectedIndex == 3 - ? const Icon(Icons.settings) - : const Icon(Icons.settings_outlined), - ), - ], - onDestinationSelected: onDestinationSelected, - ), - ); - } -} diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..7747e1f --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,42 @@ +import 'package:cafe_analog_app/app/app_brightness_provider.dart'; +import 'package:cafe_analog_app/app/dependencies_provider.dart'; +import 'package:cafe_analog_app/app/router.dart'; +import 'package:flutter/material.dart'; + +class App extends StatefulWidget { + const App({super.key}); + + @override + State createState() => _AppState(); +} + +class _AppState extends State { + // TODO(marfavi): Remove theme switching when all widgets support system theme + Brightness _brightness = Brightness.light; + + void _setBrightness(Brightness brightness) { + setState(() => _brightness = brightness); + } + + @override + Widget build(BuildContext context) { + return DependenciesProvider( + child: MaterialApp.router( + routerConfig: AnalogGoRouter.instance.goRouter, + theme: ThemeData( + brightness: _brightness, + colorScheme: ColorScheme.fromSeed( + brightness: _brightness, + seedColor: const Color(0xFF785B38), + ), + ), + builder: (context, child) { + return AppBrightnessProvider( + onBrightnessChanged: _setBrightness, + child: child!, + ); + }, + ), + ); + } +} diff --git a/lib/app/app_brightness_provider.dart b/lib/app/app_brightness_provider.dart new file mode 100644 index 0000000..4d3b8a1 --- /dev/null +++ b/lib/app/app_brightness_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class AppBrightnessProvider extends InheritedWidget { + const AppBrightnessProvider({ + required this.onBrightnessChanged, + required super.child, + super.key, + }); + + final void Function(Brightness) onBrightnessChanged; + + static AppBrightnessProvider of(BuildContext context) { + final result = context + .dependOnInheritedWidgetOfExactType(); + assert(result != null, 'No AppBrightnessProvider found in context'); + return result!; + } + + @override + bool updateShouldNotify(AppBrightnessProvider oldWidget) { + return onBrightnessChanged != oldWidget.onBrightnessChanged; + } +} diff --git a/lib/bootstrap.dart b/lib/app/bootstrap.dart similarity index 94% rename from lib/bootstrap.dart rename to lib/app/bootstrap.dart index 558f731..ed46c28 100644 --- a/lib/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -27,6 +27,8 @@ Future bootstrap(FutureOr Function() builder) async { Bloc.observer = const AppBlocObserver(); + WidgetsFlutterBinding.ensureInitialized(); + // Add cross-flavor configuration here runApp(await builder()); diff --git a/lib/app/dependencies_provider.dart b/lib/app/dependencies_provider.dart new file mode 100644 index 0000000..dd57090 --- /dev/null +++ b/lib/app/dependencies_provider.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/core/http_client.dart'; +import 'package:cafe_analog_app/core/network_request_executor.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; +import 'package:cafe_analog_app/login/data/login_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:logger/logger.dart'; + +/// Provides the dependencies required throughout the app. +class DependenciesProvider extends StatelessWidget { + const DependenciesProvider({required this.child, super.key}); + + final MaterialApp child; + + @override + Widget build(BuildContext context) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider(create: (_) => const FlutterSecureStorage()), + RepositoryProvider.value(value: apiV1), + RepositoryProvider.value(value: apiV2), + RepositoryProvider(create: (_) => Logger()), + RepositoryProvider( + create: (context) => NetworkRequestExecutor(logger: context.read()), + ), + RepositoryProvider( + create: (context) => + LoginRepository(apiV2: context.read(), executor: context.read()), + ), + RepositoryProvider( + create: (context) => + AuthTokenRepository(secureStorage: context.read()), + ), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) { + final authCubit = AuthCubit( + authTokenRepository: context.read(), + loginRepository: context.read(), + ); + unawaited(authCubit.start()); + return authCubit; + }, + ), + ], + child: child, + ), + ); + } +} diff --git a/lib/app/navigation_scaffolds.dart b/lib/app/navigation_scaffolds.dart new file mode 100644 index 0000000..2d2cd2d --- /dev/null +++ b/lib/app/navigation_scaffolds.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ScaffoldWithNestedNavigation extends StatelessWidget { + const ScaffoldWithNestedNavigation({ + required this.navigationShell, + super.key, + }); + + final StatefulNavigationShell navigationShell; + + void _goBranch(int index) { + navigationShell.goBranch( + index, + // A common pattern when using bottom navigation bars is to support + // navigating to the initial location when tapping the item that is + // already active. This example demonstrates how to support this behavior, + // using the initialLocation parameter of goBranch. + initialLocation: index == navigationShell.currentIndex, + ); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return ScaffoldWithNavigationBar( + body: navigationShell, + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: _goBranch, + ); + }, + ); + } +} + +class ScaffoldWithNavigationBar extends StatelessWidget { + const ScaffoldWithNavigationBar({ + required this.body, + required this.selectedIndex, + required this.onDestinationSelected, + super.key, + }); + final Widget body; + final int selectedIndex; + final ValueChanged onDestinationSelected; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: body, + bottomNavigationBar: NavigationBar( + selectedIndex: selectedIndex, + destinations: [ + NavigationDestination( + label: 'Tickets', + icon: selectedIndex == 0 + ? const Icon(Icons.confirmation_num) + : const Icon(Icons.confirmation_num_outlined), + ), + NavigationDestination( + label: 'Receipts', + icon: selectedIndex == 1 + ? const Icon(Icons.receipt) + : const Icon(Icons.receipt_outlined), + ), + NavigationDestination( + label: 'Stats', + icon: selectedIndex == 2 + ? const Icon(Icons.leaderboard) + : const Icon(Icons.leaderboard_outlined), + ), + NavigationDestination( + label: 'Settings', + icon: selectedIndex == 3 + ? const Icon(Icons.settings) + : const Icon(Icons.settings_outlined), + ), + ], + onDestinationSelected: onDestinationSelected, + ), + ); + } +} diff --git a/lib/app/router.dart b/lib/app/router.dart new file mode 100644 index 0000000..ac5dbe7 --- /dev/null +++ b/lib/app/router.dart @@ -0,0 +1,278 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/app/navigation_scaffolds.dart'; +import 'package:cafe_analog_app/app/splash_screen.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/ui/authentication_navigator.dart'; +import 'package:cafe_analog_app/login/ui/email_sent_screen.dart'; +import 'package:cafe_analog_app/login/ui/login_screen.dart'; +import 'package:cafe_analog_app/login/ui/verify_magic_link_screen.dart'; +import 'package:cafe_analog_app/receipts/receipts_screen.dart'; +import 'package:cafe_analog_app/redeem_voucher/redeem_voucher_screen.dart'; +import 'package:cafe_analog_app/settings/settings_screen.dart'; +import 'package:cafe_analog_app/settings/your_profile_screen.dart'; +import 'package:cafe_analog_app/stats/view/stats_screen.dart'; +import 'package:cafe_analog_app/tickets/buy_tickets/buy_tickets_screen.dart'; +import 'package:cafe_analog_app/tickets/buy_tickets/product.dart'; +import 'package:cafe_analog_app/tickets/buy_tickets/ticket_detail_screen.dart'; +import 'package:cafe_analog_app/tickets/my_tickets/tickets_screen.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +class AnalogGoRouter { + AnalogGoRouter._internal(); + + static final AnalogGoRouter instance = AnalogGoRouter._internal(); + + late final goRouter = GoRouter( + initialLocation: '/', + debugLogDiagnostics: kDebugMode, + routes: routes, + onEnter: onEnter, + redirect: redirect, + ); + + late final routes = [ + // Root shell that listens to auth state changes + ShellRoute( + builder: (_, _, child) => AuthNavigator(child: child), + routes: [ + // Splash screen shown at app start + GoRoute( + path: '/', + pageBuilder: (_, _) => const NoTransitionPage(child: SplashScreen()), + ), + GoRoute( + path: '/login', + pageBuilder: (_, _) => CustomTransitionPage( + child: const LoginScreen(), + transitionsBuilder: (_, animation, _, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + routes: [ + GoRoute( + path: 'email-sent', + pageBuilder: (_, state) { + final email = state.uri.queryParameters['email'] ?? ''; + return MaterialPage(child: EmailSentScreen(email: email)); + }, + ), + GoRoute( + path: 'auth/:token', + pageBuilder: (_, state) => CustomTransitionPage( + child: VerifyMagicLinkScreen( + magicLinkToken: state.pathParameters['token']!, + ), + transitionsBuilder: (_, animation, _, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + ), + ], + ), + GoRoute( + path: '/verify-mobilepay/:id', + pageBuilder: (_, state) => MaterialPage( + // TODO(marfavi): Implement MobilePay verification screen + child: Container(), + ), + ), + StatefulShellRoute.indexedStack( + // fade in the main scaffold (doesn't affect branch transitions) + pageBuilder: (_, _, shell) => CustomTransitionPage( + child: ScaffoldWithNestedNavigation(navigationShell: shell), + transitionsBuilder: (_, animation, _, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/tickets', + pageBuilder: (context, state) => const NoTransitionPage( + child: TicketsScreen(), + ), + routes: [ + GoRoute( + path: 'buy', + builder: (_, _) => const BuyTicketsScreen(), + routes: [ + GoRoute( + path: 'ticket/:id', + pageBuilder: (context, state) { + // we don't use id here, but in a real app you might + // fetch the product details based on the id + // we pass the whole product via extra + // + // cast state.extra to Product + final product = state.extra! as Product; + return MaterialPage( + fullscreenDialog: true, + child: TicketDetailScreen(product: product), + ); + }, + ), + ], + ), + GoRoute( + path: 'redeem_voucher', + pageBuilder: (context, state) => const MaterialPage( + child: RedeemVoucherScreen(), + ), + ), + ], + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/receipts', + pageBuilder: (context, state) => const NoTransitionPage( + child: ReceiptsScreen(), + ), + routes: [ + GoRoute( + path: 'purchase_receipt/:id', + // TODO(marfavi): Implement receipt screen + builder: (context, state) => Container(), + ), + GoRoute( + path: 'swipe_receipt/:id', + // TODO(marfavi): Implement receipt screen + builder: (context, state) => Container(), + ), + ], + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/stats', + pageBuilder: (context, state) => + const NoTransitionPage(child: StatsScreen()), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/settings', + pageBuilder: (context, state) => + const NoTransitionPage(child: SettingsScreen()), + routes: [ + GoRoute( + path: 'your-profile', + pageBuilder: (context, state) => + const MaterialPage(child: YourProfileScreen()), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ]; + + FutureOr redirect(BuildContext context, GoRouterState state) { + final loc = state.matchedLocation; + final isLoggedIn = context.read().state is AuthAuthenticated; + + // User is going anywhere within [/login, /login/email-sent, /login/auth/] + final goingToLoginFlow = loc.startsWith('/login'); + + // User is specifically accessing the app via a magic link (/login/auth/) + final goingToAuthenticate = loc.startsWith('/login/auth/'); + + // User is starting the app + final isStartingApp = loc == '/'; + + // If not logged in, always go to login unless already going there + // (or starting the app, which will handle redirection itself) + if (!isLoggedIn && + !goingToLoginFlow && + !goingToAuthenticate && + !isStartingApp) { + if (kDebugMode) { + print('Redirecting to /login'); + } + return '/login'; + } + + // If logged in and accessing app via login deep link, redirect to main app + if (isLoggedIn && goingToAuthenticate) { + // Show a snackbar after the frame is rendered + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('You are already logged in.')), + ); + }); + return '/tickets'; + } + + // If logged in and going to login, redirect to main app + if (isLoggedIn && goingToLoginFlow) { + return '/tickets'; + } + + // No need to redirect at all + return null; + } + + FutureOr onEnter( + BuildContext context, + GoRouterState currentState, + GoRouterState nextState, + GoRouter goRouter, + ) { + final currentLoc = currentState.matchedLocation; + final nextLoc = nextState.matchedLocation; + final isLoggedIn = context.read().state is AuthAuthenticated; + + // User is going anywhere within [/login, /login/email-sent, /login/auth/] + final goingToLoginFlow = nextLoc.startsWith('/login'); + + // User is starting the app + final isStartingApp = nextLoc == '/'; + + // We consider the 'main' app sections to be the branches under the shell. + final isInMainArea = + currentLoc.startsWith('/tickets') || + currentLoc.startsWith('/receipts') || + currentLoc.startsWith('/stats') || + currentLoc.startsWith('/settings'); + + // If the user is in the main app area and trying to go to the login flow + // while already logged in, block the navigation and show a snackbar. + if (isLoggedIn && goingToLoginFlow && isInMainArea) { + if (kDebugMode) { + print('Navigation to $nextLoc blocked: already logged in.'); + } + return Block.then( + () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('You are already logged in.')), + ), + ); + } + // If the user is not logged in and trying to go to the main app area, + // block the navigation and show a snackbar. + if (!isLoggedIn && !goingToLoginFlow && !isStartingApp) { + if (kDebugMode) { + print('Navigation to $nextLoc blocked: not logged in.'); + } + return Block.then( + () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please log in to continue.')), + ), + ); + } + return const Allow(); + } +} diff --git a/lib/app/splash_screen.dart b/lib/app/splash_screen.dart new file mode 100644 index 0000000..e5a70ae --- /dev/null +++ b/lib/app/splash_screen.dart @@ -0,0 +1,19 @@ +import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; +import 'package:cafe_analog_app/tickets/use_ticket/delayed_fade_in.dart'; +import 'package:flutter/material.dart'; + +class SplashScreen extends StatelessWidget { + const SplashScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: DelayedFadeIn( + delay: Duration(seconds: 1), + child: AnalogCircularProgressIndicator(spinnerColor: .dark), + ), + ), + ); + } +} diff --git a/lib/core/loading_overlay.dart b/lib/core/loading_overlay.dart new file mode 100644 index 0000000..398f79f --- /dev/null +++ b/lib/core/loading_overlay.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; +import 'package:cafe_analog_app/tickets/use_ticket/delayed_fade_in.dart'; +import 'package:flutter/material.dart'; + +/// Shows a loading overlay dialog. +/// +/// The dialog can be dismissed by popping the navigator, e.g. `context.pop()`. +void showLoadingOverlay(BuildContext context) { + unawaited( + showDialog( + context: context, + barrierColor: Theme.of(context).colorScheme.surface.withAlpha(225), + barrierDismissible: false, + builder: (_) => const Center( + child: DelayedFadeIn( + child: AnalogCircularProgressIndicator(spinnerColor: .dark), + ), + ), + ), + ); +} diff --git a/lib/core/widgets/analog_circular_progress_indicator.dart b/lib/core/widgets/analog_circular_progress_indicator.dart new file mode 100644 index 0000000..f815500 --- /dev/null +++ b/lib/core/widgets/analog_circular_progress_indicator.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +enum SpinnerColor { light, dark } + +class AnalogCircularProgressIndicator extends StatelessWidget { + const AnalogCircularProgressIndicator({ + required this.spinnerColor, + super.key, + }); + + final SpinnerColor spinnerColor; + + @override + Widget build(BuildContext context) { + return CircularProgressIndicator( + semanticsLabel: 'Loading', + strokeWidth: 10, + strokeCap: .round, + color: spinnerColor == .dark + ? Theme.of(context).colorScheme.onSurface + : Colors.white, + ); + } +} diff --git a/lib/core/widgets/form.dart b/lib/core/widgets/form.dart index 05680df..85b5293 100644 --- a/lib/core/widgets/form.dart +++ b/lib/core/widgets/form.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; /// The type of validation to apply to the form input. enum FormInputType { email, nonEmptyString } @@ -99,7 +100,6 @@ class _AnalogFormState extends State { children: [ TextFormField( controller: _controller, - autofocus: true, keyboardType: widget.inputType == FormInputType.email ? TextInputType.emailAddress : TextInputType.text, @@ -109,7 +109,7 @@ class _AnalogFormState extends State { decoration: InputDecoration( labelText: widget.labelText, filled: true, - helperText: widget.hintText, + helperText: widget.hintText ?? '', helperMaxLines: 2, helperStyle: const TextStyle( fontSize: 12, @@ -119,6 +119,7 @@ class _AnalogFormState extends State { errorMaxLines: 2, ), ), + const Gap(8), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.secondary, diff --git a/lib/core/widgets/screen.dart b/lib/core/widgets/screen.dart index 83f0706..13ec63d 100644 --- a/lib/core/widgets/screen.dart +++ b/lib/core/widgets/screen.dart @@ -1,4 +1,4 @@ -import 'package:cafe_analog_app/app.dart'; +import 'package:cafe_analog_app/app/app_brightness_provider.dart'; import 'package:cafe_analog_app/core/widgets/app_bar.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; diff --git a/lib/login/bloc/authentication_cubit.dart b/lib/login/bloc/authentication_cubit.dart new file mode 100644 index 0000000..f89bc82 --- /dev/null +++ b/lib/login/bloc/authentication_cubit.dart @@ -0,0 +1,98 @@ +import 'package:bloc/bloc.dart'; +import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:cafe_analog_app/login/data/login_repository.dart'; +import 'package:cafe_analog_app/login/ui/authentication_navigator.dart'; +import 'package:equatable/equatable.dart'; + +part 'authentication_state.dart'; + +/// Cubit responsible for managing authentication state. +/// +/// It handles login, logout, token refresh, and emits appropriate +/// states based on the authentication status. These states are used by +/// [AuthNavigator] to navigate the user through the app. +class AuthCubit extends Cubit { + AuthCubit({ + required AuthTokenRepository authTokenRepository, + required LoginRepository loginRepository, + }) : _authTokenRepository = authTokenRepository, + _loginRepository = loginRepository, + super(const AuthInitial()); + + final AuthTokenRepository _authTokenRepository; + final LoginRepository _loginRepository; + + /// Check current authentication status and emit appropriate state. + Future start() async { + emit(const AuthLoading()); + final newState = await _authTokenRepository + .getTokens() + .match( + (couldNotGetTokens) => AuthFailure(reason: couldNotGetTokens.reason), + (maybeTokens) => maybeTokens.match( + AuthUnauthenticated.new, // on none + (tokens) => AuthAuthenticated(tokens: tokens), // on some + ), + ) + .run(); + emit(newState); + } + + /// Log the user out and clear stored tokens. + Future logOut() async { + emit(const AuthLoading()); + final newState = await _authTokenRepository + .clearTokens() + .match( + (couldNotClear) => AuthFailure(reason: couldNotClear.reason), + (_) => const AuthUnauthenticated(), + ) + .run(); + emit(newState); + } + + /// The user has requested a login magic link to be sent. + Future sendLoginLink({required String email}) async { + emit(const AuthLoading()); + final newState = await _loginRepository + .requestMagicLink(email) + .match( + (didNotSendLink) => AuthFailure(reason: didNotSendLink.reason), + (_) => AuthEmailSent(email: email), + ) + .run(); + emit(newState); + } + + /// Authenticate the user with the token provided from the magic link. + Future authenticateWithToken({required String magicLinkToken}) async { + emit(const AuthLoading()); + + // Exchange the magic link token for auth tokens. + final authenticateEither = await _loginRepository + .authenticateWithMagicLinkToken(magicLinkToken) + .run(); + + // If authentication failed, emit failure. + // Otherwise save tokens and emit authenticated. + final newState = await authenticateEither.match( + (didNotAuth) async => AuthFailure(reason: didNotAuth.reason), + (tokens) async { + final saveEither = await _authTokenRepository.saveTokens(tokens).run(); + return saveEither.match( + (couldNotSave) => AuthFailure(reason: couldNotSave.reason), + (savedTokens) => AuthAuthenticated(tokens: savedTokens), + ); + }, + ); + + emit(newState); + } + + /// Refresh the JWT token. + Future refreshToken({required AuthTokens tokens}) async { + // FIXME(marfavi): implement token refresh logic + throw UnimplementedError(); + } +} diff --git a/lib/login/bloc/authentication_state.dart b/lib/login/bloc/authentication_state.dart new file mode 100644 index 0000000..8bcb8bc --- /dev/null +++ b/lib/login/bloc/authentication_state.dart @@ -0,0 +1,52 @@ +part of 'authentication_cubit.dart'; + +sealed class AuthState extends Equatable { + const AuthState(); + + @override + List get props => []; +} + +/// The cubit was initialized but no action has been taken yet. +final class AuthInitial extends AuthState { + const AuthInitial(); +} + +/// Some action is happening (such as requesting a magic link, clearing saved +/// tokens from local storage, etc). +final class AuthLoading extends AuthState { + const AuthLoading(); +} + +/// The user is successfully authenticated. +final class AuthAuthenticated extends AuthState { + const AuthAuthenticated({required this.tokens}); + final AuthTokens tokens; + + @override + List get props => [tokens]; +} + +/// A login magic link has been sent to the user's email. +final class AuthEmailSent extends AuthState { + const AuthEmailSent({required this.email}); + final String email; + + @override + List get props => [email]; +} + +/// The user is not authenticated. +final class AuthUnauthenticated extends AuthState { + const AuthUnauthenticated(); +} + +/// Some failure occurred during an authentication-related operation (such as +/// requesting a magic link, clearing saved tokens from local storage, etc). +final class AuthFailure extends AuthState { + const AuthFailure({required this.reason}); + final String reason; + + @override + List get props => [reason]; +} diff --git a/lib/login/data/authentication_token_repository.dart b/lib/login/data/authentication_token_repository.dart new file mode 100644 index 0000000..e2272b1 --- /dev/null +++ b/lib/login/data/authentication_token_repository.dart @@ -0,0 +1,64 @@ +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:fpdart/fpdart.dart'; + +/// Handles storing and retrieving JWT and refresh tokens securely. +class AuthTokenRepository { + AuthTokenRepository({required FlutterSecureStorage secureStorage}) + : _secureStorage = secureStorage; + + final FlutterSecureStorage _secureStorage; + + static const _jwtKey = 'jwt_token'; + static const _refreshTokenKey = 'refresh_token'; + + /// Saves authentication tokens securely. + /// + /// Returns the saved tokens on success. + TaskEither saveTokens(AuthTokens tokens) { + return TaskEither.tryCatch( + () async { + await Future.wait([ + _secureStorage.write(key: _jwtKey, value: tokens.jwt), + _secureStorage.write( + key: _refreshTokenKey, + value: tokens.refreshToken, + ), + ]); + return tokens; + }, + (error, _) => LocalStorageFailure('Failed to save auth tokens: $error'), + ); + } + + /// Retrieves the authentication tokens, if they exist. + TaskEither> getTokens() { + return TaskEither.tryCatch( + () async { + final jwt = await _secureStorage.read(key: _jwtKey); + final refreshToken = await _secureStorage.read(key: _refreshTokenKey); + if (jwt != null && refreshToken != null) { + return some(AuthTokens(jwt: jwt, refreshToken: refreshToken)); + } + return none(); + }, + (error, _) => + LocalStorageFailure('Failed to retrieve auth tokens: $error'), + ); + } + + /// Clears all authentication tokens (logout). + TaskEither clearTokens() { + return TaskEither.tryCatch( + () async { + await Future.wait([ + _secureStorage.delete(key: _jwtKey), + _secureStorage.delete(key: _refreshTokenKey), + ]); + return unit; + }, + (error, _) => LocalStorageFailure('Failed to clear auth tokens: $error'), + ); + } +} diff --git a/lib/login/data/authentication_tokens.dart b/lib/login/data/authentication_tokens.dart new file mode 100644 index 0000000..58262ca --- /dev/null +++ b/lib/login/data/authentication_tokens.dart @@ -0,0 +1,12 @@ +import 'package:equatable/equatable.dart'; + +/// Represents authentication tokens received after a successful authentication. +class AuthTokens extends Equatable { + const AuthTokens({required this.jwt, required this.refreshToken}); + + final String jwt; + final String refreshToken; + + @override + List get props => [jwt, refreshToken]; +} diff --git a/lib/login/data/login_repository.dart b/lib/login/data/login_repository.dart new file mode 100644 index 0000000..35e0347 --- /dev/null +++ b/lib/login/data/login_repository.dart @@ -0,0 +1,43 @@ +import 'package:cafe_analog_app/core/failures.dart'; +import 'package:cafe_analog_app/core/network_request_executor.dart'; +import 'package:cafe_analog_app/generated/api/client_index.dart'; +import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.enums.swagger.dart'; +import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.models.swagger.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:fpdart/fpdart.dart'; + +/// Handles data operations related to user login. +class LoginRepository { + const LoginRepository({ + required CoffeecardApiV2 apiV2, + required NetworkRequestExecutor executor, + }) : _apiV2 = apiV2, + _executor = executor; + + final CoffeecardApiV2 _apiV2; + final NetworkRequestExecutor _executor; + + /// Requests a magic link to be sent to the provided email. + TaskEither requestMagicLink(String email) { + final request = UserLoginRequest( + email: email, + loginType: LoginType.app.value, + ); + return _executor + .execute(() => _apiV2.accountLoginPost(body: request)) + .map((_) => unit); + } + + /// Authenticates the user with the provided magic link token. + TaskEither authenticateWithMagicLinkToken(String token) { + final request = TokenLoginRequest(token: token); + return _executor + .execute(() => _apiV2.accountAuthPost(body: request)) + .map( + (response) => AuthTokens( + jwt: response.jwt, + refreshToken: response.refreshToken, + ), + ); + } +} diff --git a/lib/login/login_screen.dart b/lib/login/login_screen.dart deleted file mode 100644 index 4d6d421..0000000 --- a/lib/login/login_screen.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:cafe_analog_app/core/http_client.dart'; -import 'package:cafe_analog_app/core/widgets/app_bar.dart'; -import 'package:cafe_analog_app/generated/api/coffeecard_api_v2.swagger.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final textEditingController = TextEditingController(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: const AnalogAppBar(title: 'Login'), - body: Form( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - AnalogTextFormField( - label: 'Email', - textInputType: TextInputType.emailAddress, - controller: textEditingController, - ), - const Gap(8), - FilledButton.icon( - label: const Text('Continue'), - icon: const Icon(Icons.navigate_next), - iconAlignment: IconAlignment.end, - onPressed: () async { - final email = textEditingController.text; - - if (kDebugMode) { - print('Trying to log in with $email...'); - } - - final response = await apiV2.accountLoginPost( - body: UserLoginRequest( - email: email, - loginType: 'shifty', - ), - ); - - if (context.mounted) { - if (response.isSuccessful) { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('EMAIL SENT'), - actions: [ - TextButton( - onPressed: () {}, - child: const Text('cool'), - ), - ], - ); - }, - ); - } else { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('ERROR'), - content: Text(response.error.toString()), - actions: [ - TextButton( - onPressed: () {}, - child: const Text('når ok'), - ), - ], - ); - }, - ); - } - } - }, - ), - ], - ), - ), - ), - ); - } -} - -class AnalogTextFormField extends StatelessWidget { - const AnalogTextFormField({ - required this.label, - required this.textInputType, - required this.controller, - super.key, - }); - - final String label; - final TextInputType textInputType; - final TextEditingController controller; - - @override - Widget build(BuildContext context) { - return TextFormField( - decoration: InputDecoration(filled: true, labelText: label), - keyboardType: textInputType, - controller: controller, - ); - } -} diff --git a/lib/login/secret_page.dart b/lib/login/secret_page.dart deleted file mode 100644 index f48c78b..0000000 --- a/lib/login/secret_page.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:cafe_analog_app/core/widgets/app_bar.dart'; -import 'package:flutter/material.dart'; - -class SecretScreen extends StatelessWidget { - const SecretScreen({required this.id, super.key}); - - final String id; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: const AnalogAppBar(title: 'OMG'), - body: Center(child: Text('SECRET PAGE!!! $id')), - ); - } -} diff --git a/lib/login/ui/authentication_navigator.dart b/lib/login/ui/authentication_navigator.dart new file mode 100644 index 0000000..2bf6b95 --- /dev/null +++ b/lib/login/ui/authentication_navigator.dart @@ -0,0 +1,72 @@ +import 'package:cafe_analog_app/core/loading_overlay.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +/// A widget that listens to authentication state changes from [AuthCubit] +/// and performs app-wide navigation and UI side effects: +/// - shows/hides a loading overlay while [AuthLoading] is emitted +/// - navigates to `/tickets`, `/login/email-sent`, or `/login` based on state +/// - displays a [SnackBar] when [AuthFailure] occurs with the failure reason +class AuthNavigator extends StatefulWidget { + const AuthNavigator({required this.child, super.key}); + + final Widget child; + + @override + State createState() => _AuthNavigatorState(); +} + +class _AuthNavigatorState extends State { + var _overlayVisible = false; + + void _showOverlay() { + if (!_overlayVisible) { + _overlayVisible = true; + showLoadingOverlay(context); + } + } + + void _hideOverlay() { + if (_overlayVisible && + Navigator.of(context, rootNavigator: true).canPop()) { + Navigator.of(context, rootNavigator: true).pop(); + _overlayVisible = false; + } + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + // Manage loading overlay reactively + if (state is AuthLoading) { + _showOverlay(); + } else { + _hideOverlay(); + } + + switch (state) { + case AuthAuthenticated(): + context.go('/tickets'); + case AuthEmailSent(): + context.go('/login/email-sent?email=${state.email}'); + case AuthUnauthenticated(): + context.go('/login'); + case AuthFailure(): + context.go('/login'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Authentication failed: ${state.reason}'), + ), + ); + case AuthLoading() || AuthInitial(): + // Do nothing + return; + } + }, + child: widget.child, + ); + } +} diff --git a/lib/login/ui/email_sent_screen.dart b/lib/login/ui/email_sent_screen.dart new file mode 100644 index 0000000..2a162e6 --- /dev/null +++ b/lib/login/ui/email_sent_screen.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; + +class EmailSentScreen extends StatefulWidget { + const EmailSentScreen({required this.email, super.key}); + + final String email; + + @override + State createState() => _EmailSentScreenState(); +} + +class _EmailSentScreenState extends State { + int _cooldownSeconds = 60; + Timer? _cooldownTimer; + + @override + void initState() { + super.initState(); + _startCooldown(); + } + + @override + void dispose() { + _cooldownTimer?.cancel(); + super.dispose(); + } + + void _startCooldown() { + setState(() => _cooldownSeconds = 60); + + _cooldownTimer?.cancel(); + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + setState(() { + _cooldownSeconds -= 1; + if (_cooldownSeconds <= 0) { + timer.cancel(); + } + }); + }); + } + + Future _resendEmail() async { + await context.read().sendLoginLink(email: widget.email); + _startCooldown(); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const .all(24), + child: Center( + child: Column( + mainAxisAlignment: .center, + children: [ + Icon( + Icons.mark_email_read_outlined, + size: 80, + color: colorScheme.secondary, + ), + const Gap(24), + Text( + 'Check your email', + style: textTheme.headlineMedium?.copyWith(fontWeight: .bold), + textAlign: .center, + ), + const Gap(12), + Text( + 'We sent a magic link to', + style: textTheme.bodyLarge, + textAlign: .center, + ), + const Gap(4), + Text( + widget.email, + style: textTheme.bodyLarge?.copyWith( + fontWeight: .bold, + color: colorScheme.primary, + ), + textAlign: .center, + ), + const Gap(8), + Text( + 'Click the link in the email to continue', + style: textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const Gap(48), + FilledButton.tonal( + style: FilledButton.styleFrom( + foregroundColor: colorScheme.onSecondary, + backgroundColor: colorScheme.secondary, + ), + onPressed: _cooldownSeconds > 0 ? null : _resendEmail, + child: Text( + _cooldownSeconds > 0 + ? 'Resend email ($_cooldownSeconds)' + : 'Resend email', + ), + ), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide(color: colorScheme.primary), + ), + onPressed: () => context.go('/login'), + icon: const Icon(Icons.chevron_left), + label: const Text('Change email'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/login/ui/login_screen.dart b/lib/login/ui/login_screen.dart new file mode 100644 index 0000000..e056820 --- /dev/null +++ b/lib/login/ui/login_screen.dart @@ -0,0 +1,69 @@ +import 'package:cafe_analog_app/core/widgets/form.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: Stack( + children: [ + // Subtle background graphic + Positioned.fill( + child: Opacity( + opacity: 0.075, + child: Image.asset('assets/images/beans_half.png', fit: .cover), + ), + ), + // Main content + SafeArea( + child: Column( + children: [ + Flexible( + fit: .tight, + child: FittedBox( + fit: .scaleDown, + child: Column( + mainAxisSize: .min, + children: [ + Text( + 'Café Analog', + style: textTheme.headlineLarge?.copyWith( + fontWeight: .w900, + ), + ), + Text( + 'Enter your email to continue', + style: textTheme.bodyMedium, + ), + ], + ), + ), + ), + AnalogForm( + inputType: .email, + labelText: 'Your email', + submitText: 'Continue', + errorMessage: 'Enter a valid email', + onSubmit: (email) async { + await context.read().sendLoginLink(email: email); + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/login/ui/verify_magic_link_screen.dart b/lib/login/ui/verify_magic_link_screen.dart new file mode 100644 index 0000000..294f172 --- /dev/null +++ b/lib/login/ui/verify_magic_link_screen.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Screen displayed when the app is opened via a magic link. +/// +/// Blank screen, only responsible for initiating the authentication +/// process using the provided magic link token. +class VerifyMagicLinkScreen extends StatefulWidget { + const VerifyMagicLinkScreen({required this.magicLinkToken, super.key}); + + final String magicLinkToken; + + @override + State createState() => _VerifyMagicLinkScreenState(); +} + +class _VerifyMagicLinkScreenState extends State { + @override + void initState() { + super.initState(); + + // Start authentication process with the provided magic link token + unawaited( + context.read().authenticateWithToken( + magicLinkToken: widget.magicLinkToken, + ), + ); + } + + @override + Widget build(BuildContext context) { + // Prevent back navigation during the authentication process + return const PopScope( + canPop: false, + child: Scaffold(), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 1608712..b73c221 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,5 @@ -import 'package:cafe_analog_app/app.dart'; -import 'package:cafe_analog_app/bootstrap.dart'; +import 'package:cafe_analog_app/app/app.dart'; +import 'package:cafe_analog_app/app/bootstrap.dart'; Future main() async { await bootstrap(() => const App()); diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index ba74d8d..dca6344 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'package:cafe_analog_app/core/widgets/screen.dart'; import 'package:cafe_analog_app/core/widgets/section_title.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -43,7 +45,7 @@ class SettingsScreen extends StatelessWidget { ListTile( leading: const Icon(Icons.logout_outlined), title: const Text('Log out'), - onTap: () {}, + onTap: () => _onLogOutTap(context), ), const Gap(24), const SectionTitle('About'), @@ -88,4 +90,30 @@ class SettingsScreen extends StatelessWidget { ], ); } + + Future _onLogOutTap(BuildContext context) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text( + 'Log out', + style: TextStyle(fontWeight: .bold), + ), + content: const Text('Are you sure you want to log out?'), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => context.pop(true), + child: const Text('Log out'), + ), + ], + ), + ); + if (confirm != true) return; + if (!context.mounted) return; + await context.read().logOut(); + } } diff --git a/pubspec.lock b/pubspec.lock index 3854c21..c72ba0e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -161,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: @@ -241,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" file: dependency: transitive description: @@ -275,6 +291,54 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 + url: "https://pub.dev" + source: hosted + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -333,6 +397,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" http: dependency: transitive description: @@ -485,6 +557,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -501,6 +581,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "7fd0c4d8ac8980011753b9bdaed2bf15111365924cdeeeaeb596214ea2b03537" + url: "https://pub.dev" + source: hosted + version: "9.2.4" package_config: dependency: transitive description: @@ -517,6 +605,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -802,6 +954,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2630a24..9a20d95 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: equatable: ^2.0.7 gap: ^3.0.1 dotted_border: ^3.1.0 + flutter_secure_storage: ^10.0.0 dev_dependencies: bloc_test: ^10.0.0 diff --git a/test/app_test.dart b/test/app_test.dart index b0510a1..5fde341 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -1,4 +1,4 @@ -import 'package:cafe_analog_app/app.dart'; +import 'package:cafe_analog_app/app/app.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/test/authentication_cubit_test.dart b/test/authentication_cubit_test.dart new file mode 100644 index 0000000..72cea67 --- /dev/null +++ b/test/authentication_cubit_test.dart @@ -0,0 +1,135 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/data/authentication_token_repository.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:cafe_analog_app/login/data/login_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockAuthRepository extends Mock implements AuthTokenRepository {} + +class _MockLoginRepository extends Mock implements LoginRepository {} + +void main() { + late _MockAuthRepository authTokenRepository; + late _MockLoginRepository loginRepository; + + setUp(() { + authTokenRepository = _MockAuthRepository(); + loginRepository = _MockLoginRepository(); + }); + + group('AuthCubit', () { + blocTest( + 'emits [LoadInProgress, Authenticated] when started ' + 'and AuthRepository reports logged in', + build: () { + when(() => authTokenRepository.getTokens()).thenReturn( + TaskEither.right( + some(const AuthTokens(jwt: 'JWT-TOKEN', refreshToken: 'REF')), + ), + ); + return AuthCubit( + authTokenRepository: authTokenRepository, + loginRepository: loginRepository, + ); + }, + act: (cubit) => cubit.start(), + expect: () => [ + isA(), + isA().having( + (s) => s.tokens.jwt, + 'jwt', + 'JWT-TOKEN', + ), + ], + ); + + blocTest( + 'emits [LoadInProgress, Unauthenticated] when started and not logged in', + build: () { + when( + () => authTokenRepository.getTokens(), + ).thenReturn(TaskEither.right(none())); + return AuthCubit( + authTokenRepository: authTokenRepository, + loginRepository: loginRepository, + ); + }, + act: (cubit) => cubit.start(), + expect: () => [ + isA(), + isA(), + ], + ); + + blocTest( + 'emits [LoadInProgress, Authenticated] ' + 'when authenticateWithMagicLinkToken succeeds', + setUp: () { + when( + () => loginRepository.authenticateWithMagicLinkToken('TOKEN'), + ).thenReturn( + TaskEither.right( + const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), + ), + ); + when( + () => authTokenRepository.saveTokens( + const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), + ), + ).thenReturn( + TaskEither.right( + const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), + ), + ); + }, + build: () => AuthCubit( + authTokenRepository: authTokenRepository, + loginRepository: loginRepository, + ), + act: (cubit) => cubit.authenticateWithToken(magicLinkToken: 'TOKEN'), + verify: (_) { + verify( + () => loginRepository.authenticateWithMagicLinkToken('TOKEN'), + ).called(1); + verify( + () => authTokenRepository.saveTokens( + const AuthTokens(jwt: 'PROVIDED-JWT', refreshToken: 'REF'), + ), + ).called(1); + }, + expect: () => [ + isA(), + isA().having( + (s) => s.tokens.jwt, + 'jwt', + 'PROVIDED-JWT', + ), + ], + ); + + blocTest( + 'emits [LoadInProgress, Unauthenticated] when logged out ' + 'and clears tokens', + build: () { + when( + () => authTokenRepository.clearTokens(), + ).thenReturn(TaskEither.right(unit)); + return AuthCubit( + authTokenRepository: authTokenRepository, + loginRepository: loginRepository, + ); + }, + act: (cubit) => cubit.logOut(), + verify: (_) { + verify(() => authTokenRepository.clearTokens()).called(1); + }, + expect: () => [ + isA(), + isA(), + ], + ); + }); +} diff --git a/test/ensure_build_test.dart b/test/ensure_build_test.dart index 35a8852..9285361 100644 --- a/test/ensure_build_test.dart +++ b/test/ensure_build_test.dart @@ -5,5 +5,6 @@ void main() { test( 'Ensure that the build is clean (i.e. no generated files are out of date)', expectBuildClean, + // skip: true, // Uncomment this line to enable build verification ); } diff --git a/test/router_test.dart b/test/router_test.dart new file mode 100644 index 0000000..bfe2053 --- /dev/null +++ b/test/router_test.dart @@ -0,0 +1,273 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:cafe_analog_app/app/router.dart'; +import 'package:cafe_analog_app/core/widgets/analog_circular_progress_indicator.dart'; +import 'package:cafe_analog_app/login/bloc/authentication_cubit.dart'; +import 'package:cafe_analog_app/login/data/authentication_tokens.dart'; +import 'package:cafe_analog_app/login/ui/authentication_navigator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockAuthCubit extends Mock implements AuthCubit {} + +void main() { + late _MockAuthCubit mockAuth; + late final goRouter = AnalogGoRouter.instance.goRouter; + + setUpAll(() { + registerFallbackValue(const AuthInitial()); + }); + + setUp(() { + mockAuth = _MockAuthCubit(); + }); + + tearDown(() { + // reset router back to root to avoid leaking state between tests + goRouter.go('/'); + }); + + testWidgets( + 'redirects to /login when not logged in', + (tester) async { + when(() => mockAuth.state).thenReturn(const AuthUnauthenticated()); + whenListen( + mockAuth, + Stream.value(const AuthUnauthenticated()), + initialState: const AuthUnauthenticated(), + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: goRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + // Try to navigate to a protected route + goRouter.go('/tickets'); + // Allow one frame for onEnter to run and show SnackBar without waiting + // for its duration + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Navigation is blocked by onEnter; ensure snackbar is shown + expect(find.text('Please log in to continue.'), findsOneWidget); + + // allow any pending timers to clean up + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'blocks navigation to /login when already logged in and in main area', + (tester) async { + when(() => mockAuth.state).thenReturn( + const AuthAuthenticated( + tokens: AuthTokens(jwt: 'j', refreshToken: 'r'), + ), + ); + whenListen( + mockAuth, + Stream.value(mockAuth.state), + initialState: mockAuth.state, + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: goRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + // start at /tickets + goRouter.go('/tickets'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // attempt to navigate into login + goRouter.go('/login'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Tickets screen should still be visible and SnackBar shown + expect(find.text('Tickets'), findsWidgets); + expect(find.text('You are already logged in.'), findsOneWidget); + + // allow any pending timers to clean up + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'shows and hides loading overlay based on AuthLoading', + (tester) async { + when(() => mockAuth.state).thenReturn(const AuthInitial()); + + // Stream controller lets us emit states with precise timing. + final ctl = StreamController(); + whenListen( + mockAuth, + ctl.stream, + initialState: const AuthInitial(), + ); + + // Use a fresh local GoRouter so AuthNavigator can call context.go safely + final testRouter = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const AuthNavigator(child: SizedBox()), + ), + GoRoute( + path: '/login', + builder: (context, state) => const SizedBox(), + ), + GoRoute( + path: '/tickets', + builder: (context, state) => const SizedBox(), + ), + ], + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: testRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + // Let initial frame build + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Count existing modal barriers and indicators + final preModalCount = tester.widgetList(find.byType(ModalBarrier)).length; + final preIndicatorCount = tester + .widgetList(find.byType(AnalogCircularProgressIndicator)) + .length; + + // Emit loading, wait a frame for the dialog to be shown + ctl.add(const AuthLoading()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Loading overlay should have added at least one modal barrier/indicator + final postModalCount = tester + .widgetList(find.byType(ModalBarrier)) + .length; + final postIndicatorCount = tester + .widgetList(find.byType(AnalogCircularProgressIndicator)) + .length; + + expect(postModalCount, greaterThan(preModalCount)); + expect(postIndicatorCount, greaterThan(preIndicatorCount)); + + // Emit a non-loading state to dismiss the overlay + ctl.add(const AuthUnauthenticated()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final finalModalCount = tester + .widgetList(find.byType(ModalBarrier)) + .length; + final finalIndicatorCount = tester + .widgetList(find.byType(AnalogCircularProgressIndicator)) + .length; + + // The modal count might not return exactly to preModalCount due to other + // modals, but the number of indicators should drop compared to when + // loading was shown. + expect(finalIndicatorCount, lessThan(postIndicatorCount)); + expect(finalModalCount, greaterThanOrEqualTo(preModalCount)); + await ctl.close(); + + // allow any pending timers (e.g., DelayedFadeIn) to finish + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'shows snackbar on AuthFailure', + (tester) async { + when(() => mockAuth.state).thenReturn(const AuthInitial()); + whenListen( + mockAuth, + Stream.fromIterable([ + const AuthInitial(), + const AuthFailure(reason: 'bad'), + ]), + initialState: const AuthInitial(), + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: goRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.text('Authentication failed: bad'), findsOneWidget); + + // Allow any delayed timers (e.g., DelayedFadeIn) to finish + await tester.pump(const Duration(seconds: 1)); + }, + ); + + testWidgets( + 'navigates to email-sent when AuthEmailSent is emitted', + (tester) async { + when(() => mockAuth.state).thenReturn(const AuthInitial()); + whenListen( + mockAuth, + Stream.fromIterable([ + const AuthInitial(), + const AuthEmailSent(email: 'user@example.com'), + ]), + initialState: const AuthInitial(), + ); + + await tester.pumpWidget( + BlocProvider.value( + value: mockAuth, + child: MaterialApp.router( + routerConfig: goRouter, + builder: (context, child) => + Scaffold(body: child ?? const SizedBox()), + ), + ), + ); + + // Wait for navigation and the screen to settle + await tester.pumpAndSettle(); + + // Verify the EmailSentScreen content is shown + expect(find.text('Check your email'), findsOneWidget); + expect(find.text('user@example.com'), findsOneWidget); + + // Allow any timers (cooldown) to start/finish + await tester.pump(const Duration(seconds: 1)); + }, + ); +}