V2.x: Anmelden mit Apple (iOS + Android)

- 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 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-07-20 16:52:49 +02:00
parent da12e3e5ab
commit 798912cb03
7 changed files with 139 additions and 9 deletions

View file

@ -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):** <https://developer.apple.com>*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 <https://appleid.apple.com>*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

View file

@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>

View file

@ -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<void> 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<void> 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<void> _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(),
});

View file

@ -67,6 +67,36 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
}
}
/// 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<void> _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<LoginScreen> {
? 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),
),
],
),
),

View file

@ -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.",

View file

@ -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:

View file

@ -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.';