From 798912cb03e9fe76ac8dd4f2ad1bdd0203c6b426 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Mon, 20 Jul 2026 16:52:49 +0200 Subject: [PATCH] V2.x: Anmelden mit Apple (iOS + Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthRepository.signInWithApple() via eingebautem AppleAuthProvider (nativ auf iOS, Web-Flow über Firebase-Handler auf Android, ohne Zusatzpaket) - Haushalts-Bootstrap in gemeinsamen Helper _bootstrapHouseholdIfNeeded ausgelagert; beim ersten Apple-Login wird Profil + Haushalt idempotent angelegt - LoginScreen: "oder"-Trenner + "Mit Apple anmelden"-Button, Abbruch ohne Fehlermeldung - l10n: signInWithApple, orDivider, authErrorAppleFailed - iOS: Entitlement com.apple.developer.applesignin ergänzt - Doku: firebase-einrichtung.md Schritt 7 (Apple-Login) mit Portal-/Console-Anleitung Noch offen: Apple-Portal (Services-ID, Sign-in-Key), Firebase-Console-Provider, Xcode-Capability — danach Ende-zu-Ende auf iOS+Android testen. Co-Authored-By: Claude Opus 4.8 --- docs/firebase-einrichtung.md | 28 ++++++++++- ios/Runner/Runner.entitlements | 4 ++ lib/features/auth/data/auth_repository.dart | 35 ++++++++++--- .../auth/presentation/login_screen.dart | 50 +++++++++++++++++++ lib/l10n/app_de.arb | 3 ++ lib/l10n/generated/app_localizations.dart | 18 +++++++ lib/l10n/generated/app_localizations_de.dart | 10 ++++ 7 files changed, 139 insertions(+), 9 deletions(-) diff --git a/docs/firebase-einrichtung.md b/docs/firebase-einrichtung.md index 3c740ef..c592868 100644 --- a/docs/firebase-einrichtung.md +++ b/docs/firebase-einrichtung.md @@ -143,9 +143,33 @@ Danach einmal neu bauen (`flutter run`) — die Erinnerungen landen dann auch au > **Stolperfalle (teuer erkauft):** „Push Notifications" fehlte in Xcode unter „+ Capability", obwohl „Background Modes" da war. Ursache: Es war noch das alte **kostenlose „Personal Team"** als Signing-Team gewählt — Gratis-Teams dürfen kein Push, deshalb blendet Xcode den Eintrag komplett aus. Fix: In *Xcode → Settings → Accounts* die Apple-ID mit der bezahlten Mitgliedschaft hinzufügen und in *Signing & Capabilities* das Team **ohne** den Zusatz `(Personal Team)` wählen. Danach erscheint „Push Notifications" in der Liste. -## Schritt 7 und folgende (kommen mit den nächsten Blöcken) +## Schritt 7: Anmelden mit Apple (iOS + Android) -- **Google-/Apple-Login** — zusätzlich zu E-Mail/Passwort (Apple-Login braucht ebenfalls den Developer Account). +**So funktioniert es:** Der Login-Screen hat neben E-Mail/Passwort einen Button „Mit Apple anmelden". Auf iOS öffnet sich der native Apple-Dialog, auf Android ein Browser-Fenster, das über den Firebase-Auth-Handler zu Apple und wieder zurück führt — beides über den in `firebase_auth` eingebauten `AppleAuthProvider` (kein Zusatz-Paket). Beim **ersten** Apple-Login legt die App automatisch Profil-Dokument und eigenen Haushalt an (wie bei der normalen Registrierung); bei „E-Mail verbergen“ wird die anonyme Apple-Relay-Adresse gespeichert. Der Code liegt in `AuthRepository.signInWithApple()`. + +**Am Code ist nichts mehr zu tun** — die folgende Konfiguration in Apple-Portal, Firebase-Konsole und Xcode macht den Button funktionsfähig: + +1. **App-ID-Capability (iOS):** In Xcode (`ios/Runner.xcworkspace`) → *Runner* → *Signing & Capabilities* → „+ Capability" → **Sign in with Apple** hinzufügen. Das Entitlement `com.apple.developer.applesignin` ist im Repo schon gesetzt; der Xcode-Schritt aktiviert die Capability zusätzlich an der App-ID `dev.leafittome.app` und frischt das Provisioning-Profil auf (mit „Automatically manage signing"). + +2. **Services-ID (für Android/Web):** → *Identifiers* → „+" → **Services IDs** → z. B. Beschreibung „LeafItToMe Sign-In", Identifier `dev.leafittome.signin` (frei wählbar, aber ≠ Bundle-ID). Nach dem Anlegen die Services-ID öffnen → **Sign In with Apple** aktivieren → **Configure**: + - *Primary App ID:* `dev.leafittome.app` + - *Domains and Subdomains:* `leaf-it-to-me-app.firebaseapp.com` + - *Return URLs:* `https://leaf-it-to-me-app.firebaseapp.com/__/auth/handler` + +3. **Sign-in-Schlüssel (.p8):** *Keys* → „+" → **Sign in with Apple** ankreuzen → Configure → Primary App ID `dev.leafittome.app` → Register → **.p8 herunterladen** (nur einmal möglich!) und **Key ID** notieren. (Eigener Schlüssel, nicht der APNs-Schlüssel aus Schritt 6.) + +4. **Firebase-Konsole:** *Authentication → Sign-in method* → **Apple** aktivieren. Für den Android/Web-Flow den Abschnitt **„OAuth-Codeflow konfigurieren"** ausfüllen: + - *Services ID:* `dev.leafittome.signin` + - *Apple Team ID:* (steht oben rechts im Developer-Account) + - *Key ID* + Inhalt der **.p8-Datei** + +Danach neu bauen (`flutter run`) und den Apple-Button auf iPhone **und** Android-Gerät testen. Erststart legt einen frischen Haushalt an — der wird in `households` sichtbar. + +> **Hinweis:** Apple liefert Name/E-Mail nur beim allerersten Login an eine App. Zum erneuten Testen des Erst-Login-Flows die App unter → *Anmelden mit Apple* → für „LeafItToMe" die Freigabe entfernen; beim nächsten Login fragt Apple wieder nach den Daten. + +## Schritt 8 und folgende (kommen mit den nächsten Blöcken) + +- **Google-Login** — zusätzlich zu E-Mail/Passwort und Apple. - **App Check** — optionale Härtung vor dem App-Store-Release: bestätigt per Play Integrity (Android) / App Attest (iOS), dass Anfragen aus der echten App kommen. Bis dahin ist die Log-Warnung „No AppCheckProvider installed" harmlos und kann ignoriert werden — die Zugriffskontrolle leisten Login + Security Rules. ## Begriffe kurz erklärt diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 903def2..80b5221 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.developer.applesignin + + Default + diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 437bae3..4772f7a 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -15,18 +15,39 @@ class AuthRepository { await _auth.signInWithEmailAndPassword(email: email, password: password); } - /// Registrierung: legt Nutzer, persönliches Profil-Dokument und einen - /// neuen Haushalt in einem Rutsch an. Der Nutzer ist automatisch Mitglied - /// seines eigenen Haushalts (V2 fügt per Einladung weitere hinzu). + /// Registrierung: legt den Auth-Nutzer an und danach Profil + Haushalt. Future signUp({required String email, required String password}) async { final credential = await _auth.createUserWithEmailAndPassword( email: email, password: password, ); - final uid = credential.user!.uid; + await _bootstrapHouseholdIfNeeded(credential.user!.uid, email); + } - final householdRef = _firestore.collection('households').doc(); + /// Anmeldung mit Apple. Nativer Dialog auf iOS, Web-Flow (über den + /// Firebase-Auth-Handler) auf Android — beides über den eingebauten + /// [AppleAuthProvider], daher ohne Zusatz-Paket. Beim **ersten** Login + /// existiert noch kein Firestore-Profil, deshalb hängt der Haushalts- + /// Bootstrap hier dran (Apple legt nur den Auth-Nutzer an). + Future signInWithApple() async { + final provider = AppleAuthProvider() + ..addScope('email') + ..addScope('name'); + final credential = await _auth.signInWithProvider(provider); + final user = credential.user!; + await _bootstrapHouseholdIfNeeded(user.uid, user.email); + } + + /// Legt Profil-Dokument und einen eigenen Haushalt an, falls für [uid] + /// noch keins existiert. Idempotent — mehrfaches Anmelden mit Apple oder + /// eine erneute Registrierung überschreibt einen bestehenden Haushalt nie. + /// [email] kann bei „E-Mail verbergen“ die Apple-Relay-Adresse sein. + Future _bootstrapHouseholdIfNeeded(String uid, String? email) async { final userRef = _firestore.collection('users').doc(uid); + if ((await userRef.get()).exists) return; + + final safeEmail = email ?? ''; + final householdRef = _firestore.collection('households').doc(); final batch = _firestore.batch(); batch.set(householdRef, { 'name': 'Mein Haushalt', @@ -35,14 +56,14 @@ class AuthRepository { 'members': { uid: { 'role': 'member', - 'email': email, + 'email': safeEmail, 'joinedAt': FieldValue.serverTimestamp(), }, }, 'createdAt': FieldValue.serverTimestamp(), }); batch.set(userRef, { - 'email': email, + 'email': safeEmail, 'householdId': householdRef.id, 'createdAt': FieldValue.serverTimestamp(), }); diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index c89d676..b4a60ea 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -67,6 +67,36 @@ class _LoginScreenState extends ConsumerState { } } + /// Codes, mit denen Firebase einen vom Nutzer abgebrochenen Apple-Dialog + /// meldet — kein Fehler, deshalb ohne rote Meldung. + static const _appleCancelCodes = { + 'canceled', + 'cancelled', + 'web-context-canceled', + 'web-context-cancelled', + 'user-cancelled', + }; + + Future _signInWithApple() async { + final l10n = AppLocalizations.of(context); + setState(() { + _busy = true; + _errorText = null; + }); + try { + await ref.read(authRepositoryProvider).signInWithApple(); + // Weiterleitung übernimmt der Router (redirect bei Auth-Änderung). + } on FirebaseAuthException catch (e) { + if (!_appleCancelCodes.contains(e.code)) { + setState(() => _errorText = l10n.authErrorAppleFailed); + } + } catch (_) { + setState(() => _errorText = l10n.authErrorAppleFailed); + } finally { + if (mounted) setState(() => _busy = false); + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -162,6 +192,26 @@ class _LoginScreenState extends ConsumerState { ? l10n.switchToSignIn : l10n.switchToSignUp), ), + const SizedBox(height: 8), + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + l10n.orDivider, + style: theme.textTheme.bodySmall, + ), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _busy ? null : _signInWithApple, + icon: const Icon(Icons.apple), + label: Text(l10n.signInWithApple), + ), ], ), ), diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 4682d65..36a7c1b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -204,6 +204,9 @@ "signUpButton": "Konto erstellen", "switchToSignUp": "Neu hier? Konto erstellen", "switchToSignIn": "Du hast schon ein Konto? Anmelden", + "orDivider": "oder", + "signInWithApple": "Mit Apple anmelden", + "authErrorAppleFailed": "Die Anmeldung mit Apple hat nicht geklappt. Bitte versuche es erneut.", "authErrorInvalidCredential": "E-Mail-Adresse oder Passwort ist falsch.", "authErrorEmailInUse": "Mit dieser E-Mail-Adresse gibt es bereits ein Konto.", "authErrorWeakPassword": "Das Passwort muss mindestens 6 Zeichen lang sein.", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 11283a5..7b0f000 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -718,6 +718,24 @@ abstract class AppLocalizations { /// **'Du hast schon ein Konto? Anmelden'** String get switchToSignIn; + /// No description provided for @orDivider. + /// + /// In de, this message translates to: + /// **'oder'** + String get orDivider; + + /// No description provided for @signInWithApple. + /// + /// In de, this message translates to: + /// **'Mit Apple anmelden'** + String get signInWithApple; + + /// No description provided for @authErrorAppleFailed. + /// + /// In de, this message translates to: + /// **'Die Anmeldung mit Apple hat nicht geklappt. Bitte versuche es erneut.'** + String get authErrorAppleFailed; + /// No description provided for @authErrorInvalidCredential. /// /// In de, this message translates to: diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a926db7..372595f 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -384,6 +384,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get switchToSignIn => 'Du hast schon ein Konto? Anmelden'; + @override + String get orDivider => 'oder'; + + @override + String get signInWithApple => 'Mit Apple anmelden'; + + @override + String get authErrorAppleFailed => + 'Die Anmeldung mit Apple hat nicht geklappt. Bitte versuche es erneut.'; + @override String get authErrorInvalidCredential => 'E-Mail-Adresse oder Passwort ist falsch.';