diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2b89254
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,76 @@
+# ------------------------------------------------------------------------------
+# Build metadata
+# ------------------------------------------------------------------------------
+FLUTTER_BUILD_NAME=1.0.0
+FLUTTER_BUILD_NUMBER=1
+
+# App identifiers
+ANDROID_APPLICATION_ID=org.stormlightlabs.lazurite
+IOS_BUNDLE_ID=org.stormlightlabs.lazurite
+
+# ------------------------------------------------------------------------------
+# Android signing (Play + side channels)
+# ------------------------------------------------------------------------------
+ANDROID_KEYSTORE_PATH=/absolute/path/upload-keystore.jks
+ANDROID_KEYSTORE_PASSWORD=
+ANDROID_KEY_ALIAS=upload
+ANDROID_KEY_PASSWORD=
+
+# Optional: if using android/key.properties templating
+ANDROID_KEY_PROPERTIES_PATH=android/key.properties
+
+# ------------------------------------------------------------------------------
+# Google Play
+# ------------------------------------------------------------------------------
+PLAY_PACKAGE_NAME=org.stormlightlabs.lazurite
+PLAY_TRACK=internal
+PLAY_CHANGES_NOT_SENT_FOR_REVIEW=true
+
+# ------------------------------------------------------------------------------
+# App Store Connect / TestFlight
+# ------------------------------------------------------------------------------
+ASC_APPLE_ID=
+ASC_TEAM_ID=
+ASC_BUNDLE_ID=org.stormlightlabs.lazurite
+ASC_KEY_ID=
+ASC_ISSUER_ID=
+ASC_API_PRIVATE_KEY_PATH=/absolute/path/AuthKey_ABC123XYZ.p8
+
+# ------------------------------------------------------------------------------
+# AltStore PAL / Classic
+# ------------------------------------------------------------------------------
+ALTSTORE_DEVELOPER_ID=
+ALTSTORE_CONTACT_EMAIL=
+ALTSTORE_MARKETPLACE_TOKEN=
+ALTSTORE_APPLE_MARKETPLACE_ID=
+ALTSTORE_SOURCE_JSON_URL=https://example.com/altstore/source.json
+ALTSTORE_ADP_HOST_ROOT=https://example.com/lazurite/adp/
+ALTSTORE_CLASSIC_IPA_URL=https://example.com/lazurite/lazurite.ipa
+
+# ------------------------------------------------------------------------------
+# Firebase / FCM
+# ------------------------------------------------------------------------------
+FIREBASE_PROJECT_ID=
+FIREBASE_ANDROID_APP_ID=
+FIREBASE_IOS_APP_ID=
+FIREBASE_ANDROID_PACKAGE_NAME=org.stormlightlabs.lazurite
+FIREBASE_IOS_BUNDLE_ID=org.stormlightlabs.lazurite
+
+# File locations in this repo
+FIREBASE_ANDROID_CONFIG_PATH=android/app/google-services.json
+FIREBASE_IOS_CONFIG_PATH=ios/Runner/GoogleService-Info.plist
+
+# APNs auth for iOS push via Firebase Cloud Messaging
+FIREBASE_APNS_KEY_ID=
+FIREBASE_APNS_TEAM_ID=
+FIREBASE_APNS_P8_PATH=/absolute/path/AuthKey_APNS.p8
+
+# ------------------------------------------------------------------------------
+# GitHub release automation
+# ------------------------------------------------------------------------------
+GITHUB_REPOSITORY=owner/repo
+GITHUB_RELEASE_TAG=v${FLUTTER_BUILD_NAME}
+GITHUB_RELEASE_NOTES_MODE=generate
+
+# Optional: API token for release automation jobs
+GITHUB_TOKEN=
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index ab0ff4e..0df05c6 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -3,6 +3,7 @@ plugins {
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
+ id("com.google.gms.google-services")
}
android {
@@ -13,6 +14,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@@ -41,3 +43,7 @@ android {
flutter {
source = "../.."
}
+
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
+}
diff --git a/android/app/google-services.json b/android/app/google-services.json
new file mode 100644
index 0000000..e98b5f1
--- /dev/null
+++ b/android/app/google-services.json
@@ -0,0 +1,29 @@
+{
+ "project_info": {
+ "project_number": "251668382991",
+ "project_id": "lazurite-push",
+ "storage_bucket": "lazurite-push.firebasestorage.app"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:251668382991:android:621a1c3c3e6afc1f66ea7a",
+ "android_client_info": {
+ "package_name": "org.stormlightlabs.lazurite"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyD0AcVccAcTyBAFqhfahqiPS8CvUHKI7Kg"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index ca7fe06..51a76cd 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -21,6 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+ id("com.google.gms.google-services") version "4.4.3" apply false
}
include(":app")
diff --git a/docs/release.md b/docs/release.md
index 853784a..383fea6 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -1,89 +1,272 @@
---
-title: Release Audit - Apple App Store + Google Play
-updated: 2026-04-15
-scope: Repository audit for likely store submission blockers or high-risk policy issues.
+title: Release and Distribution Guide
+updated: 2026-05-02
---
-## Sources
-
-- Apple App Review Guidelines:
- - 1.2 User-Generated Content requirements
- - 5.1.1 Data Collection and Storage (Privacy Policies)
- - 4.8 Login Services (third-party login exception for service clients)
-- Google Play User Data policy:
- - Privacy Policy requirement
- - Account deletion requirement (if account creation is supported)
-- Google Play Developer Programme Policy (UGC section):
- - UGC terms acceptance + moderation expectations
-
-## Findings
-
-### No in-app Privacy Policy link/text
-
-- Policy mapping:
- - Apple 5.1.1(i): privacy policy must be linked in App Store Connect and in-app in an easily accessible manner.
- - Google Play User Data: privacy policy link/text must exist in Play Console and in-app.
-- Evidence in app:
- - Login has no privacy/terms surface: `lib/features/auth/presentation/login_screen.dart` (see UI around lines 54-205).
- - Settings has no legal/privacy entry: `lib/features/settings/presentation/settings_screen.dart` (lines 69-151).
- - About page has external links and email only, no privacy policy link: `lib/features/settings/presentation/about_screen.dart` (lines 8-97).
- - Existing backlog confirms missing policy: `docs/TODO.md` (lines 62-65).
-- Impact: High probability of rejection by both stores until fixed.
-
-### High Risk (Google Play UGC): No explicit Terms/User Policy acceptance before posting UGC
-
-- Policy mapping:
- - Google Play UGC policy requires robust moderation, including requiring acceptance of Terms of Use and/or user policy before users create/upload UGC.
-- Evidence in app:
- - Compose allows direct posting with no terms acceptance gate: `lib/features/compose/presentation/compose_screen.dart` (lines 567-588, especially Post action at 584-587).
- - No in-app terms/user policy screen found in `lib/`.
-- Impact: Elevated Play policy risk for social/UGC apps.
-
-### Moderate Risk (Apple UGC 1.2): Posting-side objectionable-content controls are not explicit
-
-- Policy mapping:
- - Apple 1.2 says UGC/social apps should include a method for filtering objectionable material from being posted.
-- Evidence:
- - Reporting/blocking exists (good):
- - `lib/features/profile/presentation/widgets/profile_action_buttons.dart` (Report/Block UI around lines 85-140).
- - `lib/features/profile/presentation/widgets/report_dialog.dart` (report flow lines 10-220).
- - Moderation controls exist for viewed content (good): `lib/features/settings/presentation/settings_screen.dart` (Moderation section lines 75-77).
- - No explicit compose-time objectionable-content filter is visible in compose flow.
-- Impact: Could pass if platform-side moderation is accepted by review, but still a non-trivial risk without clear reviewer notes.
-
-### Reviewer-access risk for App Store
-
-- Apple "Before You Submit" requires full reviewer access (demo account or demo mode for account-based features).
-- App is account-based and login-gated; no repo evidence of dedicated reviewer/demo path.
-- Impact: Common review delay/rejection if review notes do not include working credentials.
- - Create a real Bluesky account for reviewers, populate it with sample content, and
- provide the credentials in App Store Connect / Play Console.
-
-## OK
-
-- In-app report mechanism exists for posts/accounts:
- - `lib/features/profile/presentation/widgets/report_dialog.dart`.
-- Block/mute actions exist:
- - `lib/features/profile/presentation/widgets/profile_action_buttons.dart`.
-- User-reachable contact info exists:
- - Email link in About: `lib/features/settings/presentation/about_screen.dart` line 11 + UI lines 90-93.
-- Sign in with Apple requirement appears likely exempt:
- - App behaves as a client for a specific third-party service (Bluesky), matching Apple 4.8 exception language.
-
-## Fixes (In Priority Order)
-
-- [x] Add a dedicated Legal screen and surface:
- - [x] Privacy Policy (in-app link + readable text summary)
- - [x] Terms of Use / User Policy
- - [x] Reachable from login and settings/about
-- [ ] Add UGC policy acceptance flow before first create/upload action (compose, media upload, messages if applicable).
-- [ ] Document moderation operations in policy/reviewer notes:
- - [ ] How reports are handled and SLA
- - [ ] What objectionable content rules apply
-- [ ] Replace placeholder identifiers and release signing setup:
- - [ ] Android `applicationId`
- - [ ] Android release signing config (non-debug)
- - [ ] iOS bundle IDs + distribution signing
-- [ ] Prepare App Store review notes with working reviewer credentials/demo path.
-- [ ] Verify account deletion obligations:
- - [ ] If any account creation is enabled in-app, add in-app deletion entry point per Apple/Google rules.
+## Shared Release Baseline
+
+1. Pin and verify toolchain.
+ - `flutter --version`
+ - `dart --version`
+2. Set version in `pubspec.yaml` and/or pass build flags.
+ - `--build-name` => human version (for example `1.4.0`)
+ - `--build-number` => monotonically increasing integer
+3. Run release gates.
+ - `flutter pub get`
+ - `flutter analyze`
+ - `gtimeout 1200s flutter test --reporter=failures-only`
+4. Tag immutable source.
+ - `git tag vX.Y.Z`
+5. Build store artifacts from that exact tag/commit.
+
+## Environment Variables
+
+Use the root `.env.example` as the canonical variable list. Keep real values in untracked secrets (`.env.local`, CI secrets manager, etc.).
+
+## Google Play (Android)
+
+### Build
+
+1. Configure real release signing (upload key), not debug signing.
+2. Build AAB (preferred by Google Play):
+
+ ```bash
+ flutter build appbundle --release \
+ --build-name "$FLUTTER_BUILD_NAME" \
+ --build-number "$FLUTTER_BUILD_NUMBER"
+ ```
+
+3. Artifact: `build/app/outputs/bundle/release/app-release.aab`
+
+### Deploy
+
+1. Enroll in Play App Signing.
+2. Upload `app-release.aab` to Internal testing first.
+3. Promote to closed/open/production after validation.
+
+### Notes
+
+- If you need the same signing key across multiple stores, provide your own app signing key when configuring Play App Signing.
+- For non-Play Android channels, ship signed APKs (Play consumes AAB; side channels consume APK).
+
+## Apple App Store (iOS)
+
+### Build
+
+1. Use an explicit App ID + matching bundle ID.
+2. Build signed IPA:
+
+ ```bash
+ flutter build ipa --release \
+ --build-name "$FLUTTER_BUILD_NAME" \
+ --build-number "$FLUTTER_BUILD_NUMBER"
+ ```
+
+3. Artifact: `build/ios/ipa/*.ipa`
+
+### Deploy
+
+1. Upload using Xcode or Transporter to App Store Connect.
+2. Wait for processing.
+3. Ship through TestFlight (internal/external) first.
+4. Submit selected build for App Review.
+
+### Notes
+
+- App Store Connect associates build using bundle ID + version + build string.
+- As of 2026, Apple requires Xcode 14+ for uploads.
+
+## AltStore.io
+
+AltStore distribution has two distinct paths.
+
+### AltStore PAL (EU marketplace path)
+
+#### Build/Package
+
+1. Build iOS release (`flutter build ipa --release`).
+2. Submit via App Store Connect with Notarization (or App Store approval, which also results in notarization).
+3. Download the Alternative Distribution Package (ADP).
+4. Host ADP exactly as-delivered; preserve directory hierarchy and do not modify `manifest.json`.
+
+#### Deploy
+
+1. Accept Apple Alternative EU Terms Addendum.
+2. Register Developer ID with AltStore PAL API.
+3. Add returned marketplace token in App Store Connect Integrations.
+4. Publish a Source JSON with required app/version metadata.
+
+### AltStore Classic (sideloaded IPA path)
+
+#### Build
+
+1. Build/sign IPA (`flutter build ipa --release`).
+2. Host IPA at stable HTTPS URL.
+
+#### Deploy
+
+1. Publish/update Source JSON.
+2. Keep newest entry first in `versions` array.
+3. Ensure each release updates `version` (`CFBundleShortVersionString`) and/or `buildVersion` (`CFBundleVersion`).
+4. Include accurate `downloadURL`, `size`, and optional `minOSVersion` / `maxOSVersion`.
+
+### Notes
+
+- AltStore determines latest release by `versions` ordering, not dates.
+- AltStore checks declared app permissions/entitlements against downloaded app package.
+
+## F-Droid
+
+### Build Strategy
+
+1. Decide target:
+ - Main F-Droid repo, or
+ - Your own F-Droid-compatible repo.
+2. For main F-Droid repo, create an `fdroid` flavor without proprietary SDKs (Firebase/Crashlytics/Play Services tracking dependencies are not allowed in main repo).
+3. Build signed deterministic APK for that flavor.
+
+Recommended flavor command pattern:
+
+```bash
+flutter build apk --release \
+ --flavor fdroid \
+ --target lib/main_fdroid.dart \
+ --build-name "$FLUTTER_BUILD_NAME" \
+ --build-number "$FLUTTER_BUILD_NUMBER"
+```
+
+### Deploy
+
+1. Prepare fdroiddata metadata and submit inclusion proposal (prefer metadata merge request path).
+2. Keep upstream releases tagged.
+3. Aim for reproducible builds from day one (strongly recommended by F-Droid, harder to retrofit later).
+
+### Notes
+
+- If Firebase push is required in Play/App Store builds, maintain a clean non-proprietary F-Droid flavor where push is disabled or replaced.
+
+## Obtainium (Android direct update channel)
+
+### Build
+
+1. Produce signed APK artifacts for direct install.
+2. Prefer a stable, machine-discoverable release URL source (typically GitHub Releases).
+
+### Deploy
+
+1. Publish release where source exposes:
+ - version identifier
+ - at least one APK download URL
+2. If multiple APK variants exist, keep filenames explicit (`arm64-v8a`, `universal`, etc.) so users can filter reliably.
+
+### Notes
+
+- Obtainium supports GitHub, GitLab, F-Droid repos, direct APK links, and HTML fallback.
+- Cross-store signing matters: updating from F-Droid-signed to non-F-Droid-signed builds may fail due to signature mismatch.
+
+## GitHub Releases
+
+### Build
+
+1. Build release artifacts from tagged commit (`vX.Y.Z`).
+2. Generate checksums for all distributables.
+
+Example:
+
+```bash
+shasum -a 256 build/app/outputs/flutter-apk/*.apk build/ios/ipa/*.ipa > checksums.txt
+```
+
+### Deploy
+
+1. Create release from tag.
+2. Attach binaries (`.aab`, `.apk`, `.ipa`, `checksums.txt`, optional symbols/maps).
+3. Use generated release notes, then curate manually.
+
+CLI example:
+
+```bash
+gh release create "v${FLUTTER_BUILD_NAME}" \
+ --generate-notes \
+ build/app/outputs/bundle/release/app-release.aab \
+ build/app/outputs/flutter-apk/*.apk \
+ build/ios/ipa/*.ipa \
+ checksums.txt
+```
+
+### Hardening (Recommended)
+
+- Add artifact attestations in GitHub Actions for build provenance.
+- Keep each release asset < 2 GiB.
+
+## Firebase Push Notifications (iOS + Android)
+
+### 1) Firebase Project and App Registration
+
+1. Install CLI tooling.
+ - `firebase login`
+ - `dart pub global activate flutterfire_cli`
+2. Run:
+
+ ```bash
+ flutterfire configure
+ ```
+
+3. Commit generated `lib/firebase_options.dart`.
+
+### 2) Platform Config Files
+
+1. Android: place `google-services.json` at `android/app/google-services.json`.
+2. iOS: place `GoogleService-Info.plist` at `ios/Runner/GoogleService-Info.plist` and include it in Runner target.
+
+### 3) Android Gradle Wiring
+
+1. Add Google services Gradle plugin in project/plugin management.
+2. Apply `com.google.gms.google-services` in app module.
+
+### 4) Apple Push Prerequisites
+
+1. In Xcode, enable `Push Notifications` capability.
+2. In Xcode Background Modes, enable:
+ - `Background fetch`
+ - `Remote notifications`
+3. Upload APNs auth key (`.p8`, Key ID, Team ID) in Firebase Console > Project Settings > Cloud Messaging.
+
+### 5) App Initialization and Runtime
+
+1. Initialize with generated options:
+
+ ```dart
+ await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
+ ```
+
+2. Request user notification permission (iOS) before expecting token delivery.
+3. Register and sync FCM token with backend; rotate on refresh.
+
+### 6) Channel-Specific Caveat
+
+- F-Droid mainline build should not include proprietary Firebase dependencies.
+Keep push-enabled binaries for Play/App Store/AltStore/Obtainium, and maintain a non-Firebase F-Droid flavor.
+
+## Primary References
+
+- Flutter Android release:
+- Flutter iOS release:
+- Android signing + Play App Signing:
+- Play App Signing help:
+- App Store Connect uploads:
+- Apple explicit App ID / bundle ID requirements: and
+- AltStore PAL distribution:
+- AltStore source format:
+- AltStore updates/version ordering:
+- F-Droid inclusion policy:
+- F-Droid inclusion workflow:
+- F-Droid reproducible builds:
+- Obtainium tracking/source behavior: and
+- GitHub releases:
+- GitHub release notes automation:
+- GitHub build provenance (artifact attestations):
+- GitHub release asset limits:
+- Firebase Flutter setup:
+- Firebase FCM Flutter setup:
+- Firebase Android config (`google-services.json` + plugin):
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index fb383a9..9a3a1a7 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -62,6 +62,7 @@
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 = ""; };
+ 9D72E1A09E8B4F5D8D8B46A1 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; };
AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
D8BAC14F436233DB60FD42F5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E85E7FAF00CB12EDA75D52D5 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
@@ -134,6 +135,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
+ 9D72E1A09E8B4F5D8D8B46A1 /* Runner.entitlements */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -487,6 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -674,6 +677,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -701,6 +705,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
diff --git a/ios/Runner/GoogleService-Info.plist b/ios/Runner/GoogleService-Info.plist
new file mode 100644
index 0000000..d06a413
--- /dev/null
+++ b/ios/Runner/GoogleService-Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ API_KEY
+ AIzaSyA3x2u6DzV_OqMpPPoDnq2ZmF7z0fjl4jk
+ GCM_SENDER_ID
+ 251668382991
+ PLIST_VERSION
+ 1
+ BUNDLE_ID
+ org.stormlightlabs.lazurite
+ PROJECT_ID
+ lazurite-push
+ STORAGE_BUCKET
+ lazurite-push.firebasestorage.app
+ IS_ADS_ENABLED
+
+ IS_ANALYTICS_ENABLED
+
+ IS_APPINVITE_ENABLED
+
+ IS_GCM_ENABLED
+
+ IS_SIGNIN_ENABLED
+
+ GOOGLE_APP_ID
+ 1:251668382991:ios:327b0e0f48ac3e7366ea7a
+
+
\ No newline at end of file
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 0a7e0b2..bf8ffe1 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -71,6 +71,7 @@
processing
fetch
+ remote-notification
UILaunchStoryboardName
LaunchScreen
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
new file mode 100644
index 0000000..903def2
--- /dev/null
+++ b/ios/Runner/Runner.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ aps-environment
+ development
+
+
diff --git a/lib/core/scheduler/post_scheduler.dart b/lib/core/scheduler/post_scheduler.dart
index f64be65..3204da1 100644
--- a/lib/core/scheduler/post_scheduler.dart
+++ b/lib/core/scheduler/post_scheduler.dart
@@ -253,7 +253,7 @@ class PostScheduler {
/// Initialises WorkManager. Call once from main() before runApp().
static Future initialize() async {
- await Workmanager().initialize(callbackDispatcher, isInDebugMode: false);
+ await Workmanager().initialize(callbackDispatcher);
}
/// Registers a one-off background task to fire at [scheduledAt].
diff --git a/lib/features/notifications/background/notification_background_worker.dart b/lib/features/notifications/background/notification_background_worker.dart
index b94fd34..3c3076e 100644
--- a/lib/features/notifications/background/notification_background_worker.dart
+++ b/lib/features/notifications/background/notification_background_worker.dart
@@ -72,7 +72,7 @@ class NotificationBackgroundScheduler {
notificationReconcileUniqueName,
notificationReconcileTaskName,
frequency: const Duration(minutes: 15),
- existingWorkPolicy: ExistingWorkPolicy.keep,
+ existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
constraints: Constraints(networkType: NetworkType.connected),
);
return;
diff --git a/lib/features/notifications/data/firebase_push_token_provider.dart b/lib/features/notifications/data/firebase_push_token_provider.dart
index 6cbb4ff..8d71b59 100644
--- a/lib/features/notifications/data/firebase_push_token_provider.dart
+++ b/lib/features/notifications/data/firebase_push_token_provider.dart
@@ -30,6 +30,14 @@ class FirebasePushTokenProvider implements PushTokenProvider {
_messaging ??= FirebaseMessaging.instance;
final messaging = _messaging!;
+ final notificationSettings = await messaging.requestPermission(
+ alert: true,
+ badge: true,
+ sound: true,
+ provisional: false,
+ );
+ log.i('Notification permission status: ${notificationSettings.authorizationStatus.name}');
+
await messaging.setAutoInitEnabled(true);
_refreshSubscription = messaging.onTokenRefresh.listen(
diff --git a/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart b/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart
index e64f938..387313a 100644
--- a/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart
+++ b/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart
@@ -1,3 +1,5 @@
+import 'dart:ui' as ui;
+
import 'package:flutter/material.dart';
/// Ensures tapping outside a focused [EditableText] dismisses keyboard focus
@@ -12,7 +14,19 @@ class GlobalTapOutsideUnfocus extends StatelessWidget {
actions: >{
EditableTextTapOutsideIntent: CallbackAction(
onInvoke: (intent) {
- intent.focusNode.unfocus();
+ // Preserve Flutter's default down-event behavior on touch so overlay
+ // interactions (like typeahead suggestion taps) are not interrupted.
+ if (intent.pointerDownEvent.kind != ui.PointerDeviceKind.touch) {
+ intent.focusNode.unfocus();
+ }
+ return null;
+ },
+ ),
+ EditableTextTapUpOutsideIntent: CallbackAction(
+ onInvoke: (intent) {
+ if (intent.pointerUpEvent.kind == ui.PointerDeviceKind.touch) {
+ intent.focusNode.unfocus();
+ }
return null;
},
),
diff --git a/pubspec.lock b/pubspec.lock
index f7eb220..5fe937e 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1809,10 +1809,34 @@ packages:
dependency: "direct main"
description:
name: workmanager
- sha256: ed13530cccd28c5c9959ad42d657cd0666274ca74c56dea0ca183ddd527d3a00
+ sha256: "065673b2a465865183093806925419d311a9a5e0995aa74ccf8920fd695e2d10"
url: "https://pub.dev"
source: hosted
- version: "0.5.2"
+ version: "0.9.0+3"
+ workmanager_android:
+ dependency: transitive
+ description:
+ name: workmanager_android
+ sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.0+2"
+ workmanager_apple:
+ dependency: transitive
+ description:
+ name: workmanager_apple
+ sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.1+2"
+ workmanager_platform_interface:
+ dependency: transitive
+ description:
+ name: workmanager_platform_interface
+ sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.1+1"
xdg_directories:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index accc3a3..2bb536d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -36,7 +36,7 @@ dependencies:
image_picker: ^1.1.2
cross_file: ^0.3.4+2
characters: ^1.4.0
- workmanager: ^0.5.2
+ workmanager: ^0.9.0+3
plugin_platform_interface: ^2.1.8
url_launcher_platform_interface: ^2.3.2
connectivity_plus: ^7.0.0
diff --git a/test/features/typeahead/presentation/typeahead_text_field_test.dart b/test/features/typeahead/presentation/typeahead_text_field_test.dart
index 6abe180..bb65beb 100644
--- a/test/features/typeahead/presentation/typeahead_text_field_test.dart
+++ b/test/features/typeahead/presentation/typeahead_text_field_test.dart
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:lazurite/features/typeahead/data/typeahead_repository.dart';
import 'package:lazurite/features/typeahead/data/typeahead_result.dart';
import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart';
+import 'package:lazurite/shared/presentation/widgets/global_tap_outside_unfocus.dart';
void main() {
group('TypeaheadTextField', () {
@@ -55,6 +56,46 @@ void main() {
expect(find.text('Alice'), findsNothing);
});
+ testWidgets('tap result still selects when wrapped in GlobalTapOutsideUnfocus', (tester) async {
+ final controller = TextEditingController();
+ TypeaheadResult? selected;
+
+ final repository = _FakeTypeaheadRepository(
+ searchHandler: ({required String query, int limit = 10}) async {
+ return const [TypeaheadResult(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice')];
+ },
+ );
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: GlobalTapOutsideUnfocus(
+ child: Scaffold(
+ body: Padding(
+ padding: const EdgeInsets.all(16),
+ child: TypeaheadTextField(
+ controller: controller,
+ repository: repository,
+ onSelected: (result) => selected = result,
+ debounceMs: 1,
+ minChars: 2,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.enterText(find.byType(TextFormField), 'alice');
+ await tester.pump(const Duration(milliseconds: 20));
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.text('Alice'));
+ await tester.pumpAndSettle();
+
+ expect(selected, isNotNull);
+ expect(selected!.did, 'did:plc:alice');
+ });
+
testWidgets('tap outside dismisses overlay', (tester) async {
final controller = TextEditingController();
final focusNode = FocusNode();
diff --git a/test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart b/test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart
index 4cecf48..1cd99a4 100644
--- a/test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart
+++ b/test/shared/presentation/widgets/global_tap_outside_unfocus_test.dart
@@ -31,4 +31,37 @@ void main() {
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isFalse);
});
+
+ testWidgets('touch down outside does not unfocus until touch up', (tester) async {
+ final focusNode = FocusNode(debugLabel: 'global-focus-test-up');
+ addTearDown(focusNode.dispose);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: GlobalTapOutsideUnfocus(
+ child: Scaffold(
+ body: Column(
+ children: [
+ TextField(focusNode: focusNode),
+ const SizedBox(height: 200),
+ const SizedBox(width: 120, height: 40, child: ColoredBox(color: Colors.red)),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byType(TextField));
+ await tester.pumpAndSettle();
+ expect(focusNode.hasFocus, isTrue);
+
+ final gesture = await tester.startGesture(const Offset(20, 260));
+ await tester.pump();
+ expect(focusNode.hasFocus, isTrue);
+
+ await gesture.up();
+ await tester.pumpAndSettle();
+ expect(focusNode.hasFocus, isFalse);
+ });
}