<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel><title>coderqh blog</title><link>https://coderqh.com/blog/</link><atom:link href="https://coderqh.com/feed.xml" rel="self" type="application/rss+xml"/><description>Flutter guides from the coderqh app templates.</description><language>en</language>
  <item><title>Structured AI output in Flutter: Gemini, OpenAI, Claude</title><link>https://coderqh.com/blog/flutter-ai-structured-output/</link><guid isPermaLink="true">https://coderqh.com/blog/flutter-ai-structured-output/</guid><pubDate>Mon, 02 Nov 2026 12:00:00 +0000</pubDate><description>Get schema-checked JSON from Gemini, OpenAI and Claude in a Flutter app with one schema: the three dialects, failures that arrive as HTTP 200, and API keys.</description><content:encoded><![CDATA[<p><strong>Short answer:</strong> all three APIs can return JSON that matches a schema you send. Each wants the schema in its own dialect: Gemini takes upper-case type names, OpenAI's strict mode needs every property required and <code>additionalProperties: false</code>, and Claude needs <code>additionalProperties: false</code> on every object. Write the schema once and convert it per vendor, or use <a href="https://pub.dev/packages/gemini_openai_claude" rel="noopener"><code>gemini_openai_claude</code></a>, which does the conversion and returns the decoded JSON.</p>
<p>If your app reads a receipt, tags a note or fills a form from a photo, you want an object back, not prose you have to parse. This guide shows how to get that from all three vendors with one schema, and how to handle the failures that look like success.</p>
<h2 id="one-request-three-dialects">One request, three dialects</h2>
<p>Here is the same small schema the way you would write it:</p>
<pre class="code"><code class="language-json">{
  &quot;type&quot;: &quot;object&quot;,
  &quot;properties&quot;: {
    &quot;title&quot;: { &quot;type&quot;: &quot;string&quot; },
    &quot;mood&quot;: { &quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;calm&quot;, &quot;busy&quot;] }
  },
  &quot;required&quot;: [&quot;title&quot;]
}</code></pre>
<p>And what each vendor actually needs:</p>
<div class="table-wrap"><table><thead><tr><th></th><th>Gemini</th><th>OpenAI (strict)</th><th>Claude</th></tr></thead><tbody><tr><td>Where the schema goes</td><td><code>generationConfig.responseSchema</code></td><td><code>response_format.json_schema</code> with <code>strict: true</code></td><td><code>output_config.format</code></td></tr><tr><td>Type names</td><td><code>STRING</code>, <code>OBJECT</code></td><td><code>string</code>, <code>object</code></td><td><code>string</code>, <code>object</code></td></tr><tr><td><code>additionalProperties: false</code></td><td>not needed</td><td>on every object</td><td>on every object</td></tr><tr><td>Optional fields</td><td>as written</td><td>not allowed: list all in <code>required</code>, make the optional ones nullable</td><td>as written</td></tr><tr><td>Optional <code>enum</code></td><td>as written</td><td>must also list <code>null</code></td><td>as written</td></tr><tr><td>Images</td><td><code>inline_data</code> parts after the text</td><td><code>image_url</code> with a <code>data:</code> URL</td><td>base64 <code>image</code> blocks before the text</td></tr></tbody></table></div>
<p>Without OpenAI's <code>strict: true</code>, the schema is only a hint and the reply may not match it. With it, an optional property makes the request fail with a 400. That is why <code>mood</code> above becomes <code>&quot;type&quot;: [&quot;string&quot;, &quot;null&quot;]</code> with <code>null</code> added to its <code>enum</code> before it goes to OpenAI.</p>
<p>The important design choice is <strong>where these conversions live</strong>. If you write the Claude conversion inside your Claude code path, the other two keep working, your tests against the default provider pass, and the feature is quietly broken for everyone who switched. Keep the conversions next to the schema so one change reaches all three.</p>
<h2 id="the-code">The code</h2>
<pre class="code"><code class="language-dart">import &#x27;package:gemini_openai_claude/gemini_openai_claude.dart&#x27;;

final spec = PromptSpec(
  instruction: &#x27;Suggest a short title and a mood for this note: &quot;Ship the release before Friday.&quot;&#x27;,
  schema: {
    &#x27;type&#x27;: &#x27;object&#x27;,
    &#x27;properties&#x27;: {
      &#x27;title&#x27;: {&#x27;type&#x27;: &#x27;string&#x27;},
      &#x27;mood&#x27;: {&#x27;type&#x27;: &#x27;string&#x27;, &#x27;enum&#x27;: [&#x27;calm&#x27;, &#x27;busy&#x27;]},
    },
    &#x27;required&#x27;: [&#x27;title&#x27;],
  },
);

final AiAdapter ai = switch (provider) {
  &#x27;gemini&#x27; =&gt; GeminiProvider(),
  &#x27;openai&#x27; =&gt; OpenAiProvider(),
  _ =&gt; ClaudeProvider(),
};
final json = await ai.complete(spec: spec, apiKey: key, model: model);
// {&quot;title&quot;: &quot;Friday release&quot;, &quot;mood&quot;: &quot;busy&quot;}</code></pre>
<p>The package is plain Dart with one dependency (<code>http</code>). It calls the REST APIs directly, so no vendor SDK update can break it. For a photo, add <code>images: [PromptImage(mime: &#x27;image/jpeg&#x27;, base64: base64Encode(bytes))]</code> to the spec.</p>
<h2 id="failures-that-arrive-as-http-200">Failures that arrive as HTTP 200</h2>
<p>Checking the status code is not enough. All three vendors can return 200 with no usable answer:</p>
<ul><li><strong>Safety block or refusal.</strong> Gemini returns no <code>content</code>, with a <code>blockReason</code> or a <code>finishReason</code> such as <code>SAFETY</code> or <code>RECITATION</code>. OpenAI fills a <code>refusal</code> field or ends with <code>content_filter</code>. Claude returns <code>stop_reason: &quot;refusal&quot;</code>.</li><li><strong>Cut off at the token limit.</strong> Gemini says <code>MAX_TOKENS</code>, OpenAI <code>finish_reason: &quot;length&quot;</code>, Claude <code>stop_reason: &quot;max_tokens&quot;</code>. The JSON looks almost valid and then stops mid-token.</li><li><strong>Empty answer.</strong> A 200 with an empty candidate list.</li></ul>
<p>If you parse these as normal replies, your app reports "the API may have changed", which sends you to read code instead of retrying. The adapters turn each case into a <code>ProviderException</code> with its own <code>kind</code>: <code>declined</code>, <code>truncated</code> or <code>emptyResult</code>.</p>
<p>The HTTP errors also deserve separate handling:</p>
<div class="table-wrap"><table><thead><tr><th>Status</th><th>Usually means</th><th>What to tell the user</th></tr></thead><tbody><tr><td>401, or 400 mentioning the key</td><td>Bad key</td><td>Check the key</td></tr><tr><td>403 on Gemini</td><td>API not enabled on the project, or region not supported</td><td>Not a key problem</td></tr><tr><td>404</td><td>Model name retired or misspelled</td><td>Choose a current model</td></tr><tr><td>429</td><td>Rate limit or billing</td><td>Wait, or switch provider</td></tr><tr><td>5xx, 529 on Claude</td><td>Vendor overloaded</td><td>Try again shortly</td></tr></tbody></table></div>
<h2 id="model-names-are-a-setting">Model names are a setting</h2>
<p>Vendors retire model names roughly once a year. If the name is a constant in your app, the feature stops working the day it is retired and stays broken until you ship an update. Make the model a setting or a remote config value, and treat a 404 as "choose a current model", not as a crash.</p>
<h2 id="where-the-api-key-lives">Where the API key lives</h2>
<p>Anything compiled into an app can be extracted, so <strong>do not ship your own API key in the binary</strong>. Two patterns are safe:</p>
<ol><li><strong>Users bring their own key.</strong> They paste it into settings, you store it in the Keychain or Keystore, and the app calls the vendor directly. Good for tools aimed at developers and power users.</li><li><strong>Your server holds the key.</strong> The app calls your backend, and the backend calls the vendor. This is the only option when you pay for the requests. Because the package is plain Dart, the same adapters can run on a Dart server.</li></ol>
<h2 id="testing-without-a-network">Testing without a network</h2>
<p>Every adapter accepts an <code>http.Client</code>, so tests use <code>MockClient</code> and never call a vendor. <code>DemoProvider</code> answers on the device from an offline answer you attach to the spec, which is useful for demo builds and screenshots:</p>
<pre class="code"><code class="language-dart">final spec = PromptSpec(
  instruction: &#x27;...&#x27;,
  schema: schema,
  demo: () =&gt; {&#x27;title&#x27;: &#x27;Friday release&#x27;, &#x27;mood&#x27;: &#x27;busy&#x27;},
);
await const DemoProvider().complete(spec: spec, apiKey: &#x27;&#x27;);</code></pre>
<h2 id="faq">FAQ</h2>
<p><strong>How do I get structured JSON output from Gemini, OpenAI and Claude with the same schema?</strong> Write one JSON Schema and convert it per vendor: upper-case types for Gemini, strict mode with every property required for OpenAI, and <code>additionalProperties: false</code> on every object for Claude. The <code>gemini_openai_claude</code> package does this for you.</p>
<p><strong>Why does OpenAI reject my schema with a 400?</strong> In strict mode every property must be listed in <code>required</code>, every object needs <code>additionalProperties: false</code>, and an optional field must be nullable, including <code>null</code> in its <code>enum</code>.</p>
<p><strong>Why does Gemini return 403 with a valid API key?</strong> The Generative Language API is not enabled on the key's project, or the region is not supported. It is a project setting, not the key.</p>
<p><strong>Is it safe to put my OpenAI or Gemini API key in a Flutter app?</strong> No. Let users bring their own key, or call the vendor from your own server.</p>
<p><strong>Do I need the vendor SDKs?</strong> No. All three have REST APIs, and a thin HTTP client is easier to keep working across SDK releases.</p>
<hr>
<p><a href="https://pub.dev/packages/gemini_openai_claude" rel="noopener"><code>gemini_openai_claude</code></a> is open source on <a href="https://github.com/coderqh/gemini_openai_claude" rel="noopener">GitHub</a>. It comes from the AI apps in our <a href="https://coderqh.com/">Flutter app templates</a>.</p>]]></content:encoded></item>
  <item><title>Sign in with Google and Apple in Flutter (2026 guide)</title><link>https://coderqh.com/blog/flutter-google-apple-sign-in/</link><guid isPermaLink="true">https://coderqh.com/blog/flutter-google-apple-sign-in/</guid><pubDate>Mon, 02 Nov 2026 12:00:00 +0000</pubDate><description>Add Google and Apple sign-in to a Flutter app on Android and iOS: OAuth clients, SHA-1 keys, App Store guideline 4.8, the Apple nonce, token checks.</description><content:encoded><![CDATA[<p><strong>Short answer:</strong> use <code>google_sign_in</code> for Google and <code>sign_in_with_apple</code> for Apple, or wrap both with <a href="https://pub.dev/packages/google_apple_sign_in" rel="noopener"><code>google_apple_sign_in</code></a>. On Android, Google needs a Web OAuth client id plus one Android client per signing key's SHA-1. On iOS, Apple needs the Sign in with Apple capability. Then send the id token to Firebase or your backend.</p>
<p>The code is the easy part. Most of the time goes into console setup, and most of the bugs only show up in release builds or in App Review. This guide walks through the four configurations in the order they usually break, then shows what to do with the token.</p>
<h2 id="what-you-are-building">What you are building</h2>
<p>A login screen with two buttons that work on both platforms:</p>
<div class="table-wrap"><table><thead><tr><th></th><th>Android</th><th>iOS</th></tr></thead><tbody><tr><td>Google</td><td>Native sheet (Credential Manager)</td><td>Native sheet</td></tr><tr><td>Apple</td><td>Web flow in a browser tab</td><td>Native sheet</td></tr></tbody></table></div>
<p>Both buttons return the same thing: an <strong>id token</strong> (a signed JWT) that proves who the user is. Your app never handles a password.</p>
<h2 id="step-0-one-interface-for-both-providers">Step 0: one interface for both providers</h2>
<p>If you call the two SDKs directly, you end up with two sets of exceptions, two result types and platform checks scattered across your UI. A thin interface keeps the login screen simple:</p>
<pre class="code"><code class="language-dart">final signIn = PlatformSocialSignIn(const SocialSignInConfig(
  googleClientId: &#x27;IOS_CLIENT_ID.apps.googleusercontent.com&#x27;,
  googleServerClientId: &#x27;WEB_CLIENT_ID.apps.googleusercontent.com&#x27;,
  appleSignIn: true,
));

// Which buttons to show on this platform
for (final provider in signIn.providers) { /* build a button */ }

try {
  final c = await signIn.signIn(SocialProvider.google);
  // c.idToken, c.email, c.displayName (and c.rawNonce for Apple)
} on SocialSignInCancelled {
  // the user closed the sheet: not an error
} on SocialSignInFailure catch (e) {
  // misconfiguration, no network, platform error
}</code></pre>
<p>That is the <a href="https://pub.dev/packages/google_apple_sign_in" rel="noopener"><code>google_apple_sign_in</code></a> package (MIT). Install it with <code>flutter pub add google_apple_sign_in</code>. Everything below applies whether you use it or call the SDKs yourself.</p>
<h2 id="step-1-google-on-android">Step 1: Google on Android</h2>
<p>Google Cloud console → APIs &amp; Services → Credentials. Configure the OAuth consent screen first if it asks.</p>
<ol><li><strong>Create a Web application client.</strong> Its id goes into <code>serverClientId</code> (<code>googleServerClientId</code> in the package). This is the step people skip: without a Web client id, Android signs the user in but returns <strong>no id token</strong>, so your backend has nothing to verify.</li><li><strong>Create an Android client for every key that signs your app.</strong> Each one needs your package name and that key's SHA-1:<ul><li>your debug key: <code>keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android</code></li><li>your upload key: the same command on your upload keystore, or <code>cd android &amp;&amp; ./gradlew signingReport</code></li><li><strong>Google Play's app signing key</strong>: Play Console → your app → App integrity → App signing.</li></ul></li></ol>
<p>The third one causes the classic "works on my phone, fails for everyone on the Play Store" bug. Play re-signs your app with its own key, so the SHA-1 your users run is not the one on your machine.</p>
<p>You do not need <code>google-services.json</code> for Google sign-in itself. You need it only if you also use Firebase.</p>
<h2 id="step-2-google-on-ios">Step 2: Google on iOS</h2>
<ol><li>Create an <strong>iOS client</strong> with your bundle id. Its id is <code>clientId</code> (<code>googleClientId</code>).</li><li>Add the <em>reversed</em> client id as a URL scheme in <code>ios/Runner/Info.plist</code>:</li></ol>
<pre class="code"><code class="language-xml">&lt;key&gt;CFBundleURLTypes&lt;/key&gt;
&lt;array&gt;
  &lt;dict&gt;
    &lt;key&gt;CFBundleURLSchemes&lt;/key&gt;
    &lt;array&gt;
      &lt;string&gt;com.googleusercontent.apps.YOUR_IOS_CLIENT_ID&lt;/string&gt;
    &lt;/array&gt;
  &lt;/dict&gt;
&lt;/array&gt;</code></pre>
<ol start="3"><li>Set the Web client id from step 1 as <code>serverClientId</code> here too. Then the token's audience is the same on both platforms, and your backend checks one value.</li></ol>
<h2 id="step-3-apple-on-ios-and-app-store-guideline-4-8">Step 3: Apple on iOS, and App Store guideline 4.8</h2>
<ol><li>Apple Developer → Certificates, Identifiers &amp; Profiles → Identifiers → your App ID → enable <strong>Sign in with Apple</strong>.</li><li>Xcode → Runner target → Signing &amp; Capabilities → <strong>+ Capability → Sign in with Apple</strong>.</li></ol>
<p>Do both before you show the button. On an App ID without the capability, every tap fails.</p>
<p><strong>Guideline 4.8</strong> is why Apple is hard to skip: an iOS app that offers a third-party login such as Google must also offer an equivalent login option that limits data to name and email, lets users hide their email, and does not track them. Sign in with Apple meets all three out of the box, so it is the usual answer, and a Google button alone on iOS is a common rejection. The package takes the safe default. <code>providers</code> hides the Google button on iOS until <code>appleSignIn</code> is true, and a debug build prints why in the console. Android shows whatever you configured.</p>
<p>Two Apple behaviors to plan for:</p>
<ul><li>Apple sends the user's <strong>name and email only on the first sign-in</strong> for that Apple ID. Save them then. Later sign-ins return no name.</li><li>The email may be a <strong>private relay address</strong> (an address at <code>privaterelay.appleid.com</code>). Treat it as a real address that forwards; do not use it as the user's identity.</li></ul>
<h2 id="step-4-apple-on-android">Step 4: Apple on Android</h2>
<p>Android has no native Apple sign-in, so the flow runs in a browser tab and comes back through your server:</p>
<ol><li>Apple Developer → Identifiers → <strong>Services IDs</strong> → create one, for example <code>com.example.app.signin</code>. That is <code>appleServiceId</code>.</li><li>Turn on Sign in with Apple for it: choose your primary App ID, add your domain, and add a <strong>Return URL</strong> on your server, for example <code>https://api.example.com/auth/apple/callback</code>. That is <code>appleRedirectUri</code>.</li><li>That endpoint receives Apple's form POST and redirects into the app: <code>intent://callback?&lt;the posted fields&gt;#Intent;package=YOUR.PACKAGE.NAME;scheme=signinwithapple;end</code></li><li>Register the callback activity in <code>AndroidManifest.xml</code>:</li></ol>
<pre class="code"><code class="language-xml">&lt;activity
    android:name=&quot;com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback&quot;
    android:exported=&quot;true&quot;&gt;
  &lt;intent-filter&gt;
    &lt;action android:name=&quot;android.intent.action.VIEW&quot; /&gt;
    &lt;category android:name=&quot;android.intent.category.DEFAULT&quot; /&gt;
    &lt;category android:name=&quot;android.intent.category.BROWSABLE&quot; /&gt;
    &lt;data android:scheme=&quot;signinwithapple&quot; android:path=&quot;/callback&quot; /&gt;
  &lt;/intent-filter&gt;
&lt;/activity&gt;</code></pre>
<p>If you do not have a server yet, skip Apple on Android. Google covers Android users, and guideline 4.8 only applies to iOS. The package offers the Apple button on Android only when <code>appleServiceId</code> is set.</p>
<h2 id="step-5-use-the-token">Step 5: use the token</h2>
<h3>With Firebase Auth</h3>
<pre class="code"><code class="language-dart">final c = await signIn.signIn(provider);
final AuthCredential cred = switch (c.provider) {
  SocialProvider.google =&gt; GoogleAuthProvider.credential(idToken: c.idToken),
  SocialProvider.apple =&gt; OAuthProvider(&#x27;apple.com&#x27;).credential(
      idToken: c.idToken,
      rawNonce: c.rawNonce,
      accessToken: c.authorizationCode,
    ),
};
await FirebaseAuth.instance.signInWithCredential(cred);</code></pre>
<p>Apple with Firebase needs a <strong>nonce</strong>: you send Apple the SHA-256 of a random string and send Firebase the raw string, so Firebase can check that the token was issued for this request. The package generates the pair and returns the raw value as <code>rawNonce</code>.</p>
<h3>With your own backend</h3>
<p>Post the id token (and <code>rawNonce</code> for Apple) and verify it on the server. Never trust the email or name the app sends alongside it.</p>
<ol><li>Check the signature against the provider's public keys: <code>https://www.googleapis.com/oauth2/v3/certs</code> for Google, <code>https://appleid.apple.com/auth/keys</code> for Apple.</li><li>Check <code>iss</code>: <code>https://accounts.google.com</code> or <code>accounts.google.com</code> for Google; <code>https://appleid.apple.com</code> for Apple.</li><li>Check <code>aud</code>: your Web client id for Google; your bundle id (iOS) or Services ID (Android) for Apple.</li><li>Check <code>exp</code>, and for Apple check that the <code>nonce</code> claim equals SHA-256 of <code>rawNonce</code>.</li><li>Create or find the user by the token's <code>sub</code>. Do not use email as the key: Apple's can be a relay, and either provider's can change.</li></ol>
<p>Most server frameworks have a JWT library that does steps 1 to 4 when you give it the keys URL and expected values.</p>
<h2 id="testing-without-real-accounts">Testing without real accounts</h2>
<p>Make your login screen depend on the <code>SocialSignIn</code> interface and inject a fake in tests:</p>
<pre class="code"><code class="language-dart">final fake = FakeSocialSignIn();
await loginController(fake).tapGoogle();
expect(fake.calls, 1);

fake.cancel = true; // the next tap behaves like the user closing the sheet</code></pre>
<p>And you can check which buttons iOS would show without a device:</p>
<pre class="code"><code class="language-dart">const c = SocialSignInConfig(googleServerClientId: &#x27;x&#x27;);
expect(c.offered(applePlatform: true), isEmpty); // guideline 4.8</code></pre>
<h2 id="checklist-before-release">Checklist before release</h2>
<ul class="checklist"><li class="check">Web client id set as <code>serverClientId</code> on both platforms</li><li class="check">Android clients for debug, upload <strong>and</strong> Play app signing SHA-1</li><li class="check">iOS reversed client id in <code>Info.plist</code></li><li class="check">Sign in with Apple enabled on the App ID and in Xcode</li><li class="check">Google not offered alone on iOS (guideline 4.8)</li><li class="check">Name and email saved on the first Apple sign-in</li><li class="check">Backend verifies signature, <code>iss</code>, <code>aud</code>, <code>exp</code> and keys users on <code>sub</code></li></ul>
<h2 id="faq">FAQ</h2>
<p><strong>Do I need Firebase to add Google and Apple sign-in to Flutter?</strong> No. Both SDKs return an id token you can verify on any backend. Firebase is one convenient place to send it.</p>
<p><strong>Why does Google sign-in return a null id token on Android?</strong> <code>serverClientId</code> is missing or set to the Android client id. It must be the Web application client id.</p>
<p><strong>Why does Google sign-in work in debug but fail from the Play Store?</strong> The SHA-1 of Google Play's app signing key is not registered. Add another Android OAuth client with that SHA-1.</p>
<p><strong>Do I have to add Sign in with Apple if my app has Google sign-in?</strong> On iOS, App Store guideline 4.8 requires an equivalent privacy-focused login next to any third-party login, and Sign in with Apple is the simplest way to meet it. On Android it is optional.</p>
<p><strong>Why does Apple not return the user's name the second time?</strong> Apple only shares the name and email on the first authorization. To test the first-time flow again, remove the app under Settings → your name → Sign-In &amp; Security → Sign in with Apple.</p>
<p><strong>Can I use Sign in with Apple on Android?</strong> Yes, through a web flow. It needs a Services ID and a server endpoint as the Return URL.</p>
<hr>
<p>The <a href="https://pub.dev/packages/google_apple_sign_in" rel="noopener"><code>google_apple_sign_in</code></a> package is open source on <a href="https://github.com/coderqh/google_apple_sign_in" rel="noopener">GitHub</a>. It comes from the login screens in our <a href="https://coderqh.com/">Flutter app templates</a>.</p>]]></content:encoded></item>
</channel></rss>
