diff --git a/.github/actions/resolve-flutter-build-metadata/action.yml b/.github/actions/resolve-flutter-build-metadata/action.yml new file mode 100644 index 0000000..d251f82 --- /dev/null +++ b/.github/actions/resolve-flutter-build-metadata/action.yml @@ -0,0 +1,78 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +name: Resolve Flutter package metadata +description: Resolve Flutter build-name/build-number values, release tag metadata, and artifact naming. + +inputs: + platform: + description: Platform name used for platform-specific release tags. + required: true + require-tag-build-number: + description: Whether platform release tags must include +BUILD_NUMBER. + required: false + default: 'true' + +outputs: + build_name: + description: Flutter build-name value. + value: ${{ steps.metadata.outputs.build_name }} + build_number: + description: Flutter build-number value. + value: ${{ steps.metadata.outputs.build_number }} + artifact_ref_name: + description: Sanitized ref name for artifact names. + value: ${{ steps.metadata.outputs.artifact_ref_name }} + is_tag_build: + description: Whether the current build resolved to a release tag. + value: ${{ steps.metadata.outputs.is_tag_build }} + +runs: + using: composite + steps: + - name: Resolve metadata + id: metadata + shell: bash + env: + PLATFORM: ${{ inputs.platform }} + REQUIRE_TAG_BUILD_NUMBER: ${{ inputs.require-tag-build-number }} + run: | + set -euo pipefail + + if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then + echo "::error::This package workflow only supports release tags." + exit 1 + fi + + tag_name="${GITHUB_REF_NAME}" + if [[ "${tag_name}" =~ ^${PLATFORM}-v([0-9]+)\.([0-9]+)\.([0-9]+)\+([0-9]+)$ ]]; then + build_name="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + build_number="${BASH_REMATCH[4]}" + else + if [[ "${REQUIRE_TAG_BUILD_NUMBER}" == "true" ]]; then + echo "::error::Invalid ${PLATFORM} release tag '${tag_name}'. Expected ${PLATFORM}-vX.Y.Z+B, for example ${PLATFORM}-v0.1.0+5." + exit 1 + fi + + if [[ ! "${tag_name}" =~ ^${PLATFORM}-v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::Invalid ${PLATFORM} release tag '${tag_name}'. Expected ${PLATFORM}-vX.Y.Z+B." + exit 1 + fi + + build_name="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + build_number="${GITHUB_RUN_NUMBER}" + fi + + artifact_ref_name="$(printf '%s' "${tag_name}" | sed 's#[^A-Za-z0-9_.+-]#-#g')" + + echo "build_name=${build_name}" >> "${GITHUB_OUTPUT}" + echo "build_number=${build_number}" >> "${GITHUB_OUTPUT}" + echo "artifact_ref_name=${artifact_ref_name}" >> "${GITHUB_OUTPUT}" + echo "is_tag_build=true" >> "${GITHUB_OUTPUT}" + + { + echo "## Package metadata" + echo + echo "- Platform: ${PLATFORM}" + echo "- Tag: ${tag_name}" + echo "- Build name: ${build_name}" + echo "- Build number: ${build_number}" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/actions/setup-flutter/action.yml b/.github/actions/setup-flutter/action.yml new file mode 100644 index 0000000..90c3f0d --- /dev/null +++ b/.github/actions/setup-flutter/action.yml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +name: Setup Flutter +description: Install a Flutter SDK from Git and add it to PATH. + +inputs: + flutter-repository: + description: Flutter SDK Git repository. + required: false + default: https://github.com/flutter/flutter.git + flutter-ref: + description: Flutter SDK branch, tag, or ref. + required: false + default: stable + +runs: + using: composite + steps: + - name: Install Flutter + shell: bash + run: | + set -euo pipefail + git clone --depth 1 --branch "${{ inputs.flutter-ref }}" "${{ inputs.flutter-repository }}" "${RUNNER_TEMP}/flutter" + echo "${RUNNER_TEMP}/flutter/bin" >> "${GITHUB_PATH}" diff --git a/.github/workflows/ios-unsigned-release.yml b/.github/workflows/ios-unsigned-release.yml new file mode 100644 index 0000000..3dee695 --- /dev/null +++ b/.github/workflows/ios-unsigned-release.yml @@ -0,0 +1,135 @@ +name: iOS Unsigned Release + +on: + push: + tags: + - 'ios-v*' + +permissions: + contents: read + +concurrency: + group: ios-unsigned-release-${{ github.ref }} + cancel-in-progress: true + +jobs: + package: + name: Package unsigned iOS IPA + runs-on: macos-latest + timeout-minutes: 60 + env: + IOS_BUNDLE_IDENTIFIER: ${{ vars.IOS_BUNDLE_IDENTIFIER }} + + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Flutter stable + uses: ./.github/actions/setup-flutter + + - name: Resolve package metadata + id: metadata + uses: ./.github/actions/resolve-flutter-build-metadata + with: + platform: ios + require-tag-build-number: 'true' + + - name: Show Xcode environment + run: | + set -euo pipefail + xcode-select -p + xcodebuild -version + swift --version + + - name: Show Flutter environment + run: flutter --version + + - name: Precache iOS Flutter artifacts + run: flutter precache --ios + + - name: Get Flutter packages + run: | + set -euo pipefail + for attempt in 1 2 3; do + if flutter pub get; then + exit 0 + fi + + echo "flutter pub get failed on attempt ${attempt}; retrying after cleaning temporary pub cache files." + rm -rf "${HOME}/.pub-cache/_temp" + sleep $((attempt * 10)) + done + + echo "::error::flutter pub get failed after 3 attempts." + exit 1 + + - name: Install iOS pods + run: | + set -euo pipefail + cd ios + pod install + + - name: Apply release bundle identifier + run: | + set -euo pipefail + if [ -z "${IOS_BUNDLE_IDENTIFIER}" ]; then + echo "::error::Set repository variable IOS_BUNDLE_IDENTIFIER before creating an iOS release tag." + exit 1 + fi + + if ! printf '%s\n' "${IOS_BUNDLE_IDENTIFIER}" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$'; then + echo "::error::IOS_BUNDLE_IDENTIFIER has an invalid bundle identifier format." + exit 1 + fi + + perl -0pi -e 's/PRODUCT_BUNDLE_IDENTIFIER = com\.example\.techpie;/PRODUCT_BUNDLE_IDENTIFIER = $ENV{IOS_BUNDLE_IDENTIFIER};/g' ios/Runner.xcodeproj/project.pbxproj + + matches=$(grep -c "PRODUCT_BUNDLE_IDENTIFIER = ${IOS_BUNDLE_IDENTIFIER};" ios/Runner.xcodeproj/project.pbxproj) + if [ "${matches}" -ne 3 ]; then + echo "::error::Expected to update 3 Runner bundle identifier settings, updated ${matches}." + exit 1 + fi + + - name: Build unsigned iOS app + run: | + set -euo pipefail + flutter build ios --release --no-codesign \ + --build-name "${{ steps.metadata.outputs.build_name }}" \ + --build-number "${{ steps.metadata.outputs.build_number }}" + test -d build/ios/iphoneos/Runner.app + actual_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' build/ios/iphoneos/Runner.app/Info.plist) + if [ "${actual_bundle_id}" != "${IOS_BUNDLE_IDENTIFIER}" ]; then + echo "::error::Expected bundle identifier ${IOS_BUNDLE_IDENTIFIER}, got ${actual_bundle_id}." + exit 1 + fi + + - name: Write unsigned artifact notice + run: | + set -euo pipefail + cat > build/ios/UNSIGNED_ARTIFACT_README.md <<'NOTICE' + # Unsigned iOS artifact + + This artifact is unsigned. It cannot be uploaded directly to TestFlight and may not install directly on a device. It is only for verifying that the source code can produce an unsigned iOS package in a macOS/Xcode environment. + NOTICE + + - name: Package unsigned IPA + run: | + set -euo pipefail + rm -rf build/ios/ipa + mkdir -p build/ios/ipa/Payload + cp -R build/ios/iphoneos/Runner.app build/ios/ipa/Payload/Runner.app + cd build/ios/ipa + ditto -c -k --sequesterRsrc --keepParent Payload TechPie-unsigned.ipa + test -f TechPie-unsigned.ipa + + - name: Upload unsigned IPA artifact + uses: actions/upload-artifact@v4 + with: + name: techpie-ios-unsigned-${{ steps.metadata.outputs.artifact_ref_name }} + retention-days: 30 + if-no-files-found: error + path: | + build/ios/UNSIGNED_ARTIFACT_README.md + build/ios/ipa/TechPie-unsigned.ipa diff --git a/ios/Runner/NativeGlass/NativeGlassButton.swift b/ios/Runner/NativeGlass/NativeGlassButton.swift index 30862ab..148d9bf 100644 --- a/ios/Runner/NativeGlass/NativeGlassButton.swift +++ b/ios/Runner/NativeGlass/NativeGlassButton.swift @@ -105,15 +105,21 @@ final class NativeGlassButtonPlatformView: NSObject, FlutterPlatformView { private func applyButtonAppearance() { let image = symbolImage() +#if compiler(>=6.2) if #available(iOS 26.0, *) { applyLiquidGlassAppearance(image: image) - } else if #available(iOS 15.0, *) { + return + } +#endif + + if #available(iOS 15.0, *) { applyModernFallbackAppearance(image: image) } else { applyLegacyFallbackAppearance(image: image) } } +#if compiler(>=6.2) @available(iOS 26.0, *) private func applyLiquidGlassAppearance(image: UIImage?) { var configuration = UIButton.Configuration.prominentGlass() @@ -121,6 +127,7 @@ final class NativeGlassButtonPlatformView: NSObject, FlutterPlatformView { button.configuration = configuration } +#endif @available(iOS 15.0, *) private func applyModernFallbackAppearance(image: UIImage?) { diff --git a/ios/Runner/NativeGlass/NativeGlassConfirmationButton.swift b/ios/Runner/NativeGlass/NativeGlassConfirmationButton.swift index 819162d..3067bdb 100644 --- a/ios/Runner/NativeGlass/NativeGlassConfirmationButton.swift +++ b/ios/Runner/NativeGlass/NativeGlassConfirmationButton.swift @@ -114,10 +114,12 @@ final class NativeGlassConfirmationButtonPlatformView: NSObject, FlutterPlatform } private func applyButtonAppearance() { +#if compiler(>=6.2) if #available(iOS 26.0, *) { applyLiquidGlassAppearance() return } +#endif let image = symbolImage(named: sfSymbol) @@ -139,6 +141,7 @@ final class NativeGlassConfirmationButtonPlatformView: NSObject, FlutterPlatform } } +#if compiler(>=6.2) @available(iOS 26.0, *) private func applyLiquidGlassAppearance() { let image = symbolImage(named: sfSymbol) @@ -155,6 +158,7 @@ final class NativeGlassConfirmationButtonPlatformView: NSObject, FlutterPlatform button.configuration = configuration } +#endif private func symbolImage(named systemName: String) -> UIImage? { return UIImage(systemName: systemName)? @@ -184,12 +188,17 @@ final class NativeGlassConfirmationButtonPlatformView: NSObject, FlutterPlatform actionSheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) if let popover = actionSheet.popoverPresentationController { +#if compiler(>=6.2) if #available(iOS 26.0, *) { popover.sourceItem = button } else { popover.sourceView = rootView popover.sourceRect = rootView.bounds } +#else + popover.sourceView = rootView + popover.sourceRect = rootView.bounds +#endif } controller.present(actionSheet, animated: true) diff --git a/ios/Runner/NativeGlass/NativeGlassDropdownMenu.swift b/ios/Runner/NativeGlass/NativeGlassDropdownMenu.swift index da29273..ec3b395 100644 --- a/ios/Runner/NativeGlass/NativeGlassDropdownMenu.swift +++ b/ios/Runner/NativeGlass/NativeGlassDropdownMenu.swift @@ -123,6 +123,7 @@ final class NativeGlassDropdownMenuPlatformView: NSObject, FlutterPlatformView { private func applyButtonAppearance() { let image = symbolImage(named: sfSymbol) +#if compiler(>=6.2) if #available(iOS 26.0, *) { var configuration = UIButton.Configuration.glass() configuration.image = image @@ -130,6 +131,7 @@ final class NativeGlassDropdownMenuPlatformView: NSObject, FlutterPlatformView { button.configuration = configuration return } +#endif if #available(iOS 15.0, *) { var configuration = UIButton.Configuration.plain() diff --git a/ios/Runner/NativeGlass/NativeGlassSelect.swift b/ios/Runner/NativeGlass/NativeGlassSelect.swift index e2a9be2..40c5bc4 100644 --- a/ios/Runner/NativeGlass/NativeGlassSelect.swift +++ b/ios/Runner/NativeGlass/NativeGlassSelect.swift @@ -135,6 +135,7 @@ final class NativeGlassSelectPlatformView: NSObject, FlutterPlatformView { private func applyButtonAppearance() { let image = symbolImage(named: sfSymbol) +#if compiler(>=6.2) if #available(iOS 26.0, *) { var configuration = UIButton.Configuration.glass() configuration.image = image @@ -142,6 +143,7 @@ final class NativeGlassSelectPlatformView: NSObject, FlutterPlatformView { button.configuration = configuration return } +#endif if #available(iOS 15.0, *) { var configuration = UIButton.Configuration.plain() @@ -246,12 +248,17 @@ final class NativeGlassSelectPlatformView: NSObject, FlutterPlatformView { sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) if let popover = sheet.popoverPresentationController { +#if compiler(>=6.2) if #available(iOS 26.0, *) { popover.sourceItem = button } else { popover.sourceView = rootView popover.sourceRect = rootView.bounds } +#else + popover.sourceView = rootView + popover.sourceRect = rootView.bounds +#endif } controller.present(sheet, animated: true) diff --git a/ios/Runner/NativeGlass/NativeNavigationBar.swift b/ios/Runner/NativeGlass/NativeNavigationBar.swift index b331fa4..478a33d 100644 --- a/ios/Runner/NativeGlass/NativeNavigationBar.swift +++ b/ios/Runner/NativeGlass/NativeNavigationBar.swift @@ -113,10 +113,12 @@ final class NativeNavigationBarPlatformView: NSObject, FlutterPlatformView { navigationItem.title = configuration.title navigationItem.largeTitleDisplayMode = configuration.largeTitleMode ? .always : .never +#if compiler(>=6.2) if #available(iOS 26.0, *) { navigationItem.subtitle = configuration.largeTitleMode ? nil : configuration.subtitle navigationItem.largeSubtitle = configuration.largeTitleMode ? configuration.subtitle : nil } +#endif if #available(iOS 16.0, *) { let leadingItems = configuration.leadingItems.map(makeBarButtonItem) @@ -220,9 +222,11 @@ final class NativeNavigationBarPlatformView: NSObject, FlutterPlatformView { if #available(iOS 16.0, *) { barButtonItem.isHidden = item.hidden } +#if compiler(>=6.2) if #available(iOS 26.0, *) { barButtonItem.identifier = item.id } +#endif return barButtonItem }