diff --git a/.github/workflows/pr-builder.yml b/.github/workflows/pr-builder.yml index c8579f6..90ecae5 100644 --- a/.github/workflows/pr-builder.yml +++ b/.github/workflows/pr-builder.yml @@ -59,6 +59,14 @@ jobs: - name: πŸ“¦ Get Dependencies run: flutter pub get + - name: πŸ“¦ Get Sample Dependencies + working-directory: samples/quickstart + run: flutter pub get + + - name: πŸ” Create Sample .env + working-directory: samples/quickstart + run: cp .env.example .env + - name: πŸ” Run Flutter Analyze run: flutter analyze @@ -123,6 +131,10 @@ jobs: working-directory: samples/quickstart run: flutter pub get + - name: πŸ” Create Sample .env + working-directory: samples/quickstart + run: cp .env.example .env + - name: πŸ”¨ Build Quickstart Sample working-directory: samples/quickstart run: flutter build apk --debug diff --git a/AGENTS.md b/AGENTS.md index b2863db..340c36e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,19 +72,19 @@ lib/ flow_template_resolver.dart Embedded-flow URL builder widgets/ thunderid_provider.dart State provider (InheritedWidget root) - thunderid_sign_in_button.dart SignInButton / BaseSignInButton - thunderid_sign_up_button.dart SignUpButton / BaseSignUpButton - thunderid_sign_out_button.dart SignOutButton / BaseSignOutButton - thunderid_sign_in.dart Embedded sign-in form - thunderid_sign_up.dart Embedded sign-up form - thunderid_signed_in.dart Guard: renders child only when authenticated - thunderid_signed_out.dart Guard: inverse of SignedIn - thunderid_loading.dart Guard: renders while SDK is loading - thunderid_user.dart User info display - thunderid_user_dropdown.dart User dropdown menu - thunderid_user_profile.dart Full profile view - thunderid_callback.dart OAuth2 redirect handler - thunderid_language_switcher.dart Locale selector + sign_in_button.dart SignInButton / BaseSignInButton + sign_up_button.dart SignUpButton / BaseSignUpButton + sign_out_button.dart SignOutButton / BaseSignOutButton + sign_in.dart Embedded sign-in form + sign_up.dart Embedded sign-up form + signed_in.dart Guard: renders child only when authenticated + signed_out.dart Guard: inverse of SignedIn + loading.dart Guard: renders while SDK is loading + user_object.dart User info display + user_dropdown.dart User dropdown menu + user_profile.dart Full profile view + callback.dart OAuth2 redirect handler + language_switcher.dart Locale selector flow_form.dart Reusable form wrapper for flow steps android/ Kotlin method channel handler (JVM 17, minSdk 26) ios/Classes/ Swift method channel handler (iOS 16+) diff --git a/lib/src/thunderid_client.dart b/lib/src/thunderid_client.dart index f6b5bca..4f59f95 100644 --- a/lib/src/thunderid_client.dart +++ b/lib/src/thunderid_client.dart @@ -17,15 +17,15 @@ */ import 'channel/thunderid_channel.dart'; -import 'models/thunderid_error.dart'; -import 'models/thunderid_config.dart'; -import 'models/user.dart'; -import 'models/user_profile.dart'; -import 'models/token_response.dart'; import 'models/flow_models.dart'; import 'models/sign_in_options.dart'; import 'models/sign_out_options.dart'; +import 'models/thunderid_config.dart'; +import 'models/thunderid_error.dart'; import 'models/token_exchange_config.dart'; +import 'models/token_response.dart'; +import 'models/user.dart'; +import 'models/user_profile.dart'; /// Flutter SDK client β€” Core Lib layer, delegates all protocol operations to /// the native iOS and Android Platform SDKs via [ThunderIDChannel] (spec Β§7.1). diff --git a/lib/src/widgets/thunderid_callback.dart b/lib/src/widgets/callback.dart similarity index 99% rename from lib/src/widgets/thunderid_callback.dart rename to lib/src/widgets/callback.dart index ebbba79..e8e634f 100644 --- a/lib/src/widgets/thunderid_callback.dart +++ b/lib/src/widgets/callback.dart @@ -17,8 +17,9 @@ */ import 'package:flutter/widgets.dart'; -import 'thunderid_provider.dart'; + import '../models/user.dart'; +import 'thunderid_provider.dart'; /// Handles the OAuth2 redirect callback URL (spec Β§8.4 Auth Flow). /// diff --git a/lib/src/widgets/flow_form.dart b/lib/src/widgets/flow_form.dart index 8e7f7ef..acc52f3 100644 --- a/lib/src/widgets/flow_form.dart +++ b/lib/src/widgets/flow_form.dart @@ -17,8 +17,9 @@ */ import 'package:flutter/material.dart'; -import '../models/flow_models.dart'; + import '../flow_template_resolver.dart'; +import '../models/flow_models.dart'; import 'thunderid_provider.dart'; /// Internal widget used by [ThunderIDSignIn] and [ThunderIDSignUp] to render a @@ -60,7 +61,9 @@ class _FlowFormState extends State { @override void dispose() { - for (final c in _controllers.values) c.dispose(); + for (final c in _controllers.values) { + c.dispose(); + } super.dispose(); } @@ -106,7 +109,7 @@ class _FlowFormState extends State { context, {'ref': _inputRef(i), 'label': '', 'type': i['type']}, _str(i['type']), - )), + ),), if (actions.isNotEmpty) ...actions.map((a) => _renderAction(context, a, actions)) else @@ -121,7 +124,7 @@ class _FlowFormState extends State { Text( widget.error!, style: TextStyle( - color: Theme.of(context).colorScheme.error, fontSize: 13), + color: Theme.of(context).colorScheme.error, fontSize: 13,), ), ], ], @@ -283,7 +286,7 @@ class _FlowFormState extends State { } String _findActionId( - String metaActionId, List> actions) { + String metaActionId, List> actions,) { if (actions.isEmpty) return 'submit'; final byRef = actions.firstWhere( (a) => _str(a['ref']) == metaActionId, @@ -304,7 +307,7 @@ class _FlowFormState extends State { String _actionSubmitId(Map a) => _str(a['ref'], fallback: _str(a['id'], - fallback: _str(a['nextNode'], fallback: 'submit'))); + fallback: _str(a['nextNode'], fallback: 'submit'),),); int? _actionIndex(String id) { if (!id.startsWith('action_')) return null; @@ -359,11 +362,11 @@ class _FlowFormState extends State { String _fieldRef(Map comp) => _str(comp['ref'], fallback: _str(comp['identifier'], - fallback: _str(comp['name'], fallback: _str(comp['id'])))); + fallback: _str(comp['name'], fallback: _str(comp['id'])),),); String _inputRef(Map input) => _str(input['name'], fallback: _str(input['identifier'], - fallback: _str(input['ref'], fallback: _str(input['id'])))); + fallback: _str(input['ref'], fallback: _str(input['id'])),),); String _resolve(dynamic value, {String fallback = ''}) { final s = value is String ? value.trim() : ''; diff --git a/lib/src/widgets/thunderid_language_switcher.dart b/lib/src/widgets/language_switcher.dart similarity index 100% rename from lib/src/widgets/thunderid_language_switcher.dart rename to lib/src/widgets/language_switcher.dart diff --git a/lib/src/widgets/thunderid_loading.dart b/lib/src/widgets/loading.dart similarity index 97% rename from lib/src/widgets/thunderid_loading.dart rename to lib/src/widgets/loading.dart index 67abe35..e3ff8f2 100644 --- a/lib/src/widgets/thunderid_loading.dart +++ b/lib/src/widgets/loading.dart @@ -48,7 +48,7 @@ class _DefaultSpinner extends StatelessWidget { // Stub β€” avoids a Material/Cupertino import at this layer. // Replace with CircularProgressIndicator when using with Flutter Material. class CircularProgressIndicatorStub extends StatelessWidget { - const CircularProgressIndicatorStub(); + const CircularProgressIndicatorStub({super.key}); @override Widget build(BuildContext context) => const SizedBox.shrink(); diff --git a/lib/src/widgets/thunderid_sign_in.dart b/lib/src/widgets/sign_in.dart similarity index 99% rename from lib/src/widgets/thunderid_sign_in.dart rename to lib/src/widgets/sign_in.dart index 8713ca8..b8f6305 100644 --- a/lib/src/widgets/thunderid_sign_in.dart +++ b/lib/src/widgets/sign_in.dart @@ -16,13 +16,14 @@ * under the License. */ -import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; -import 'thunderid_provider.dart'; -import 'flow_form.dart'; +import 'package:flutter/material.dart'; + import '../models/flow_models.dart'; import '../models/token_exchange_config.dart'; import '../models/user.dart'; +import 'flow_form.dart'; +import 'thunderid_provider.dart'; /// State exposed to [BaseThunderIDSignIn]'s builder. class ThunderIDSignInState { @@ -108,7 +109,7 @@ class _BaseSignInState extends State { try { final state = ThunderIDProvider.of(context); final response = await state.client.signIn( - payload: EmbeddedSignInPayload(actionId: 'init'), + payload: const EmbeddedSignInPayload(actionId: 'init'), request: EmbeddedFlowRequestConfig(applicationId: widget.applicationId), ); if (kDebugMode) { diff --git a/lib/src/widgets/thunderid_sign_in_button.dart b/lib/src/widgets/sign_in_button.dart similarity index 100% rename from lib/src/widgets/thunderid_sign_in_button.dart rename to lib/src/widgets/sign_in_button.dart diff --git a/lib/src/widgets/thunderid_sign_out_button.dart b/lib/src/widgets/sign_out_button.dart similarity index 99% rename from lib/src/widgets/thunderid_sign_out_button.dart rename to lib/src/widgets/sign_out_button.dart index 318fbab..c635563 100644 --- a/lib/src/widgets/thunderid_sign_out_button.dart +++ b/lib/src/widgets/sign_out_button.dart @@ -17,8 +17,9 @@ */ import 'package:flutter/widgets.dart'; -import 'thunderid_provider.dart'; + import '../models/sign_out_options.dart'; +import 'thunderid_provider.dart'; /// Triggers sign-out on tap. Accessible per WCAG 2.1 AA (spec Β§8.1). class SignOutButton extends StatelessWidget { diff --git a/lib/src/widgets/thunderid_sign_up.dart b/lib/src/widgets/sign_up.dart similarity index 95% rename from lib/src/widgets/thunderid_sign_up.dart rename to lib/src/widgets/sign_up.dart index 0ea6093..35c2e98 100644 --- a/lib/src/widgets/thunderid_sign_up.dart +++ b/lib/src/widgets/sign_up.dart @@ -17,10 +17,11 @@ */ import 'package:flutter/widgets.dart'; -import 'thunderid_provider.dart'; -import 'flow_form.dart'; + import '../models/flow_models.dart'; import '../models/token_exchange_config.dart'; +import 'flow_form.dart'; +import 'thunderid_provider.dart'; /// Full registration form driving the REGISTRATION flow (spec Β§8.4 Presentation). class SignUp extends StatelessWidget { @@ -49,13 +50,13 @@ class SignUp extends StatelessWidget { } } -class _FlowState { +class ThunderIDSignUpState { final EmbeddedFlowResponse? currentStep; final bool isLoading; final String? error; final Future Function(String actionId, Map inputs) submit; - const _FlowState({ + const ThunderIDSignUpState({ required this.currentStep, required this.isLoading, required this.error, @@ -68,7 +69,7 @@ class BaseSignUp extends StatefulWidget { final String applicationId; final VoidCallback? onSuccess; final VoidCallback? onError; - final Widget Function(BuildContext context, _FlowState state) builder; + final Widget Function(BuildContext context, ThunderIDSignUpState state) builder; const BaseSignUp({ super.key, @@ -98,7 +99,7 @@ class _BaseSignUpState extends State { try { final state = ThunderIDProvider.of(context); final response = await state.client.signUp( - payload: EmbeddedSignInPayload(actionId: 'init'), + payload: const EmbeddedSignInPayload(actionId: 'init'), request: EmbeddedFlowRequestConfig( applicationId: widget.applicationId, flowType: FlowType.registration, @@ -165,7 +166,7 @@ class _BaseSignUpState extends State { @override Widget build(BuildContext context) => widget.builder( context, - _FlowState( + ThunderIDSignUpState( currentStep: _currentStep, isLoading: _isLoading, error: _error, diff --git a/lib/src/widgets/thunderid_sign_up_button.dart b/lib/src/widgets/sign_up_button.dart similarity index 99% rename from lib/src/widgets/thunderid_sign_up_button.dart rename to lib/src/widgets/sign_up_button.dart index 966941b..5351285 100644 --- a/lib/src/widgets/thunderid_sign_up_button.dart +++ b/lib/src/widgets/sign_up_button.dart @@ -17,9 +17,10 @@ */ import 'package:flutter/widgets.dart'; -import 'thunderid_provider.dart'; -import '../models/sign_up_options.dart'; + import '../models/flow_models.dart'; +import '../models/sign_up_options.dart'; +import 'thunderid_provider.dart'; /// Initiates the sign-up flow on tap (spec Β§8.4 Actions). class SignUpButton extends StatelessWidget { diff --git a/lib/src/widgets/thunderid_signed_in.dart b/lib/src/widgets/signed_in.dart similarity index 100% rename from lib/src/widgets/thunderid_signed_in.dart rename to lib/src/widgets/signed_in.dart diff --git a/lib/src/widgets/thunderid_signed_out.dart b/lib/src/widgets/signed_out.dart similarity index 100% rename from lib/src/widgets/thunderid_signed_out.dart rename to lib/src/widgets/signed_out.dart diff --git a/lib/src/widgets/thunderid_provider.dart b/lib/src/widgets/thunderid_provider.dart index 14d25fc..0a70ebb 100644 --- a/lib/src/widgets/thunderid_provider.dart +++ b/lib/src/widgets/thunderid_provider.dart @@ -19,11 +19,12 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; -import '../thunderid_client.dart'; -import '../models/thunderid_config.dart'; + +import '../i18n/thunderid_i18n.dart'; import '../models/preferences.dart'; +import '../models/thunderid_config.dart'; import '../models/user.dart'; -import '../i18n/thunderid_i18n.dart'; +import '../thunderid_client.dart'; /// Provides a [ThunderIDClient] and reactive authentication state to the widget tree. /// diff --git a/lib/src/widgets/thunderid_user_dropdown.dart b/lib/src/widgets/user_dropdown.dart similarity index 99% rename from lib/src/widgets/thunderid_user_dropdown.dart rename to lib/src/widgets/user_dropdown.dart index 14e90ca..88602e3 100644 --- a/lib/src/widgets/thunderid_user_dropdown.dart +++ b/lib/src/widgets/user_dropdown.dart @@ -17,8 +17,9 @@ */ import 'package:flutter/widgets.dart'; -import 'thunderid_provider.dart'; + import '../models/user.dart'; +import 'thunderid_provider.dart'; /// Avatar chip that opens a menu with profile and sign-out actions (spec Β§8.4 Presentation). class UserDropdown extends StatelessWidget { diff --git a/lib/src/widgets/thunderid_user.dart b/lib/src/widgets/user_object.dart similarity index 97% rename from lib/src/widgets/thunderid_user.dart rename to lib/src/widgets/user_object.dart index 3681091..37b41a9 100644 --- a/lib/src/widgets/thunderid_user.dart +++ b/lib/src/widgets/user_object.dart @@ -17,8 +17,10 @@ */ import 'package:flutter/widgets.dart'; -import 'thunderid_provider.dart'; + +import '../i18n/thunderid_i18n.dart'; import '../models/user.dart'; +import 'thunderid_provider.dart'; /// Read-only display of the authenticated user's name and avatar (spec Β§8.4 Presentation). class UserObject extends StatelessWidget { @@ -35,7 +37,7 @@ class UserObject extends StatelessWidget { class _DefaultUserLayout extends StatelessWidget { final User? user; - final dynamic i18n; + final ThunderIDI18n i18n; const _DefaultUserLayout({required this.user, required this.i18n}); diff --git a/lib/src/widgets/thunderid_user_profile.dart b/lib/src/widgets/user_profile.dart similarity index 98% rename from lib/src/widgets/thunderid_user_profile.dart rename to lib/src/widgets/user_profile.dart index 6f2928d..758183c 100644 --- a/lib/src/widgets/thunderid_user_profile.dart +++ b/lib/src/widgets/user_profile.dart @@ -17,9 +17,11 @@ */ import 'dart:convert'; + import 'package:flutter/material.dart'; -import 'thunderid_provider.dart'; + import '../models/user_profile.dart' as model; +import 'thunderid_provider.dart'; /// Read-only profile view built from the decoded access token. class UserProfile extends StatelessWidget { @@ -140,7 +142,9 @@ class _BaseUserProfileState extends State { @override void dispose() { - for (final c in _controllers.values) c.dispose(); + for (final c in _controllers.values) { + c.dispose(); + } super.dispose(); } @@ -164,7 +168,7 @@ class _BaseUserProfileState extends State { final profile = model.UserProfile(id: payload['sub'] as String? ?? '', claims: profileClaims); for (final entry in profileClaims.entries) { _controllers.putIfAbsent( - entry.key, () => TextEditingController(text: entry.value?.toString() ?? '')); + entry.key, () => TextEditingController(text: entry.value?.toString() ?? ''),); } if (mounted) setState(() => _profile = profile); } catch (e) { diff --git a/lib/thunderid_flutter.dart b/lib/thunderid_flutter.dart index d4a8b50..ff55ce9 100644 --- a/lib/thunderid_flutter.dart +++ b/lib/thunderid_flutter.dart @@ -23,30 +23,30 @@ /// ThunderID Platform SDKs via Flutter platform channels. library thunderid_flutter; -export 'src/thunderid_client.dart'; -export 'src/models/thunderid_config.dart'; -export 'src/models/user.dart'; -export 'src/models/user_profile.dart' hide UserProfile; -export 'src/models/token_response.dart'; -export 'src/models/thunderid_error.dart'; +export 'src/flow_template_resolver.dart'; export 'src/models/flow_models.dart'; +export 'src/models/preferences.dart'; export 'src/models/sign_in_options.dart'; export 'src/models/sign_out_options.dart'; export 'src/models/sign_up_options.dart'; +export 'src/models/thunderid_config.dart'; +export 'src/models/thunderid_error.dart'; export 'src/models/token_exchange_config.dart'; -export 'src/models/preferences.dart'; +export 'src/models/token_response.dart'; +export 'src/models/user.dart'; +export 'src/models/user_profile.dart' hide UserProfile; +export 'src/thunderid_client.dart'; +export 'src/widgets/callback.dart'; +export 'src/widgets/language_switcher.dart'; +export 'src/widgets/loading.dart'; +export 'src/widgets/sign_in.dart'; +export 'src/widgets/sign_in_button.dart'; +export 'src/widgets/sign_out_button.dart'; +export 'src/widgets/sign_up.dart'; +export 'src/widgets/sign_up_button.dart'; +export 'src/widgets/signed_in.dart'; +export 'src/widgets/signed_out.dart'; export 'src/widgets/thunderid_provider.dart'; -export 'src/widgets/thunderid_sign_in_button.dart'; -export 'src/widgets/thunderid_sign_out_button.dart'; -export 'src/widgets/thunderid_sign_up_button.dart'; -export 'src/widgets/thunderid_callback.dart'; -export 'src/widgets/thunderid_signed_in.dart'; -export 'src/widgets/thunderid_signed_out.dart'; -export 'src/widgets/thunderid_loading.dart'; -export 'src/widgets/thunderid_sign_in.dart'; -export 'src/widgets/thunderid_sign_up.dart'; -export 'src/widgets/thunderid_user.dart'; -export 'src/widgets/thunderid_user_dropdown.dart'; -export 'src/widgets/thunderid_user_profile.dart'; -export 'src/widgets/thunderid_language_switcher.dart'; -export 'src/flow_template_resolver.dart'; +export 'src/widgets/user_dropdown.dart'; +export 'src/widgets/user_object.dart'; +export 'src/widgets/user_profile.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 28245c5..6a00387 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,11 +13,11 @@ dependencies: plugin_platform_interface: ^2.1.7 dev_dependencies: + build_runner: ^2.4.8 + flutter_lints: ^3.0.0 flutter_test: sdk: flutter - flutter_lints: ^3.0.0 mockito: ^5.4.4 - build_runner: ^2.4.8 flutter: plugin: diff --git a/samples/quickstart/.gitignore b/samples/quickstart/.gitignore index 809f405..48a41e5 100644 --- a/samples/quickstart/.gitignore +++ b/samples/quickstart/.gitignore @@ -7,7 +7,9 @@ .buildlog/ .history - +# Local ThunderID dev server certificate (machine-specific, see Flutter Quickstart docs) +server.cert +server.key # Flutter repo-specific /bin/cache/ diff --git a/samples/quickstart/.metadata b/samples/quickstart/.metadata index 3e79a8d..0b1aaad 100644 --- a/samples/quickstart/.metadata +++ b/samples/quickstart/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" + revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: ios - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 + create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 + base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 + - platform: android + create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 + base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7 # User provided section diff --git a/samples/quickstart/android/.gitignore b/samples/quickstart/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/samples/quickstart/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/samples/quickstart/android/app/build.gradle.kts b/samples/quickstart/android/app/build.gradle.kts new file mode 100644 index 0000000..55d73d4 --- /dev/null +++ b/samples/quickstart/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "dev.thunderid.flutter_quickstart" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "dev.thunderid.flutter_quickstart" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 26 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/samples/quickstart/android/app/src/debug/AndroidManifest.xml b/samples/quickstart/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/samples/quickstart/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/samples/quickstart/android/app/src/main/AndroidManifest.xml b/samples/quickstart/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bd098c0 --- /dev/null +++ b/samples/quickstart/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/quickstart/android/app/src/main/kotlin/dev/thunderid/flutter_quickstart/MainActivity.kt b/samples/quickstart/android/app/src/main/kotlin/dev/thunderid/flutter_quickstart/MainActivity.kt new file mode 100644 index 0000000..096090a --- /dev/null +++ b/samples/quickstart/android/app/src/main/kotlin/dev/thunderid/flutter_quickstart/MainActivity.kt @@ -0,0 +1,5 @@ +package dev.thunderid.flutter_quickstart + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/samples/quickstart/android/app/src/main/res/drawable-v21/launch_background.xml b/samples/quickstart/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/samples/quickstart/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/samples/quickstart/android/app/src/main/res/drawable/launch_background.xml b/samples/quickstart/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/samples/quickstart/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/samples/quickstart/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/samples/quickstart/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/samples/quickstart/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/samples/quickstart/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/samples/quickstart/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/samples/quickstart/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/samples/quickstart/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/samples/quickstart/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/samples/quickstart/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/samples/quickstart/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/samples/quickstart/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/samples/quickstart/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/samples/quickstart/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/samples/quickstart/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/samples/quickstart/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/samples/quickstart/android/app/src/main/res/values-night/styles.xml b/samples/quickstart/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/samples/quickstart/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/samples/quickstart/android/app/src/main/res/values/styles.xml b/samples/quickstart/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/samples/quickstart/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/samples/quickstart/android/app/src/profile/AndroidManifest.xml b/samples/quickstart/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/samples/quickstart/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/samples/quickstart/android/build.gradle.kts b/samples/quickstart/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/samples/quickstart/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/samples/quickstart/android/flutter_quickstart_android.iml b/samples/quickstart/android/flutter_quickstart_android.iml new file mode 100644 index 0000000..3bc4b3b --- /dev/null +++ b/samples/quickstart/android/flutter_quickstart_android.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/quickstart/android/gradle.properties b/samples/quickstart/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/samples/quickstart/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/samples/quickstart/android/gradle/wrapper/gradle-wrapper.properties b/samples/quickstart/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/samples/quickstart/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/samples/quickstart/android/settings.gradle.kts b/samples/quickstart/android/settings.gradle.kts new file mode 100644 index 0000000..9d55230 --- /dev/null +++ b/samples/quickstart/android/settings.gradle.kts @@ -0,0 +1,33 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} + +includeBuild("../../../android") { + name = "android" + dependencySubstitution { + substitute(module("dev.thunderid:android")).using(project(":")) + } +} + +include(":app") diff --git a/samples/quickstart/flutter_quickstart.iml b/samples/quickstart/flutter_quickstart.iml new file mode 100644 index 0000000..f66303d --- /dev/null +++ b/samples/quickstart/flutter_quickstart.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/samples/quickstart/ios/Podfile b/samples/quickstart/ios/Podfile index a05ffed..25d398e 100644 --- a/samples/quickstart/ios/Podfile +++ b/samples/quickstart/ios/Podfile @@ -29,7 +29,7 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! - pod 'ThunderID', :path => '../../../../tools/sdks/ios' + pod 'ThunderID', :git => 'https://github.com/thunder-id/ios-sdks' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do diff --git a/samples/quickstart/ios/Runner.xcodeproj/project.pbxproj b/samples/quickstart/ios/Runner.xcodeproj/project.pbxproj index bd3cea6..be17f43 100644 --- a/samples/quickstart/ios/Runner.xcodeproj/project.pbxproj +++ b/samples/quickstart/ios/Runner.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 1C8A4D7A5E6B4C3D2A190001 /* server.cert in Resources */ = {isa = PBXBuildFile; fileRef = 1C8A4D7A5E6B4C3D2A190002 /* server.cert */; }; 25604BB0C5D4253EA9F66207 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8E69A3A38C99D0AD4E4C2F2D /* Pods_Runner.framework */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; @@ -45,7 +44,6 @@ /* Begin PBXFileReference section */ 0BA1155FF8054CAFB9195FE7 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 1C8A4D7A5E6B4C3D2A190002 /* server.cert */ = {isa = PBXFileReference; lastKnownFileType = text; name = server.cert; path = ../server.cert; sourceTree = SOURCE_ROOT; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; @@ -157,7 +155,6 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( - 1C8A4D7A5E6B4C3D2A190002 /* server.cert */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -266,7 +263,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1C8A4D7A5E6B4C3D2A190001 /* server.cert in Resources */, 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, diff --git a/samples/quickstart/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/samples/quickstart/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..3d095f2 --- /dev/null +++ b/samples/quickstart/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "ios-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/thunder-id/ios-sdks", + "state" : { + "branch" : "main", + "revision" : "1ed224e4697acb0dace4604de6c2eeeb5ff9c353" + } + } + ], + "version" : 2 +} diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..d41fd82 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..0418749 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..b8df5ec 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..81bea86 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..2c7701b 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..92298e2 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..ff48b7b 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..6cd356c 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..5b95fb9 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..4cc3cf3 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..4cc3cf3 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..9e7be17 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..0f02bae 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..4fd28ab 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..1ee055d 100644 Binary files a/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/samples/quickstart/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/samples/quickstart/ios/Runner/Info.plist b/samples/quickstart/ios/Runner/Info.plist index 0043fd1..26fe2bb 100644 --- a/samples/quickstart/ios/Runner/Info.plist +++ b/samples/quickstart/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Flutter B2c + Quickstart CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -66,5 +66,10 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/samples/quickstart/lib/app.dart b/samples/quickstart/lib/app.dart index d1382b8..5e8abf4 100644 --- a/samples/quickstart/lib/app.dart +++ b/samples/quickstart/lib/app.dart @@ -27,9 +27,9 @@ class QuickstartApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'ACME Booking', + title: 'ThunderID Quickstart', theme: ThemeData( - colorSchemeSeed: const Color(0xFFFF5A5F), + colorSchemeSeed: const Color(0xFF3688FF), useMaterial3: true, ), home: const _RootScreen(), @@ -52,7 +52,7 @@ class _RootScreen extends StatelessWidget { children: [ CircularProgressIndicator(), SizedBox(height: 12), - Text('Starting ACME Booking\u2026'), + Text('Starting ThunderID…'), ], ), ), diff --git a/samples/quickstart/lib/screens/auth_screen.dart b/samples/quickstart/lib/screens/auth_screen.dart index 3c278d2..767661b 100644 --- a/samples/quickstart/lib/screens/auth_screen.dart +++ b/samples/quickstart/lib/screens/auth_screen.dart @@ -20,8 +20,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:thunderid_flutter/thunderid_flutter.dart'; -enum _AuthMode { signIn, signUp } - class AuthScreen extends StatefulWidget { const AuthScreen({super.key}); @@ -30,74 +28,351 @@ class AuthScreen extends StatefulWidget { } class _AuthScreenState extends State { - _AuthMode _mode = _AuthMode.signIn; + static const _blue = Color(0xFF3688FF); + static const _muted = Color(0xFF5A7085); + static const _darkBg = Color(0xFF080f1c); + static const _lightBg = Color(0xFFF7F9FC); + + static const _featureTags = ['OAuth 2.0', 'PKCE', 'JWT', 'MFA', 'SSO']; + + void _showSheet(BuildContext context, String mode) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _AuthSheet(initialMode: mode), + ); + } @override Widget build(BuildContext context) { - final applicationId = dotenv.env['THUNDERID_APP_ID'] ?? ''; - final cs = Theme.of(context).colorScheme; + final brightness = Theme.of(context).brightness; + final isDark = brightness == Brightness.dark; + final bgColor = isDark ? _darkBg : _lightBg; + final textColor = isDark ? Colors.white : const Color(0xFF05213F); + + const logoHeight = 72.0; + const logoWidth = logoHeight * (207 / 257); return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40), + backgroundColor: bgColor, + body: Stack( + children: [ + SafeArea( + child: Column( + children: [ + // ── Identity section ──────────────────────────────────────── + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CustomPaint( + painter: _TdMarkPainter(dark: isDark), + size: const Size(logoWidth, logoHeight), + ), + const SizedBox(height: 28), + Text( + 'Authentication for developers.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + letterSpacing: -1.4, + color: textColor, + height: 1.2, + ), + ), + const SizedBox(height: 12), + const Text( + 'OAuth 2.0, PKCE, MFA, and JWT β€” out of the box in minutes.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: _muted, + height: 1.5, + ), + ), + const SizedBox(height: 20), + SizedBox( + height: 36, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _featureTags.map((tag) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.all( + color: _blue.withValues(alpha: 0.4), + ), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + tag, + style: const TextStyle( + fontSize: 12, + color: _blue, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ), + ), + ), + + // ── Bottom CTAs ───────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton.icon( + onPressed: () => _showSheet(context, 'signup'), + icon: const Icon(Icons.arrow_forward, size: 18), + label: const Text('Get started'), + style: ElevatedButton.styleFrom( + backgroundColor: _blue, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => _showSheet(context, 'login'), + style: OutlinedButton.styleFrom( + foregroundColor: _blue, + side: const BorderSide(color: _blue), + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + child: const Text('Sign in'), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Auth bottom sheet +// ───────────────────────────────────────────────────────────────────────────── + +class _AuthSheet extends StatefulWidget { + final String initialMode; + const _AuthSheet({required this.initialMode}); + + @override + State<_AuthSheet> createState() => _AuthSheetState(); +} + +class _AuthSheetState extends State<_AuthSheet> { + late String _mode; + + @override + void initState() { + super.initState(); + _mode = widget.initialMode; + } + + @override + Widget build(BuildContext context) { + final applicationId = dotenv.env['THUNDERID_APP_ID'] ?? ''; + final brightness = Theme.of(context).brightness; + final isDark = brightness == Brightness.dark; + final sheetBg = isDark ? const Color(0xFF0d1627) : Colors.white; + + return DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (ctx, scrollController) { + return Container( + decoration: BoxDecoration( + color: sheetBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Column( - children: [ - CircleAvatar( - radius: 36, - backgroundColor: cs.primary, - child: Icon(Icons.home_filled, size: 36, color: cs.onPrimary), - ), - const SizedBox(height: 16), - Text( - 'ACME Booking', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - 'Find your perfect stay', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: cs.onSurfaceVariant), - ), - ], + const SizedBox(height: 12), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), ), ), - const SizedBox(height: 40), - SegmentedButton<_AuthMode>( - segments: const [ - ButtonSegment(value: _AuthMode.signIn, label: Text('Sign In')), - ButtonSegment(value: _AuthMode.signUp, label: Text('Create Account')), - ], - selected: {_mode}, - onSelectionChanged: (s) => setState(() => _mode = s.first), - ), - const SizedBox(height: 28), - Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: cs.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: _mode == _AuthMode.signIn - ? SignIn(applicationId: applicationId) - : SignUp(applicationId: applicationId), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), + child: _buildSheetContent(ctx, applicationId), ), ), ], ), - ), - ), + ); + }, ); } + + Widget _buildSheetContent(BuildContext context, String applicationId) { + switch (_mode) { + case 'login': + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SignIn(applicationId: applicationId), + const SizedBox(height: 12), + TextButton( + onPressed: () => setState(() => _mode = 'recover'), + child: const Text('Forgot password?'), + ), + TextButton( + onPressed: () => setState(() => _mode = 'signup'), + child: const Text("Don't have an account? Create one"), + ), + ], + ); + case 'signup': + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SignUp(applicationId: applicationId), + const SizedBox(height: 12), + TextButton( + onPressed: () => setState(() => _mode = 'login'), + child: const Text('Already have an account? Sign in'), + ), + ], + ); + case 'recover': + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + const Text( + 'Password recovery', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Enter your email address and we\'ll send you a link to reset your password.', + style: TextStyle(fontSize: 14, color: Color(0xFF5A7085)), + ), + const SizedBox(height: 24), + TextButton( + onPressed: () => setState(() => _mode = 'login'), + child: const Text('Back to sign in'), + ), + ], + ); + default: + return const SizedBox.shrink(); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// ThunderID logo mark painter +// ───────────────────────────────────────────────────────────────────────────── + +class _TdMarkPainter extends CustomPainter { + final bool dark; + const _TdMarkPainter({required this.dark}); + + @override + void paint(Canvas canvas, Size size) { + final scale = size.height / 257; + + canvas.save(); + canvas.scale(scale, scale); + + // Path 1 β€” dark fill (onSurface color) + final darkPaint = Paint() + ..color = dark ? Colors.white : const Color(0xFF05213F) + ..style = PaintingStyle.fill; + + final path1 = Path() + ..moveTo(55.4763, 26.4391) + ..lineTo(58.8866, 0) + ..lineTo(0, 0) + ..lineTo(0, 26.4391) + ..close(); + + canvas.drawPath(path1, darkPaint); + + // Path 2 & 3 β€” blue fill + final bluePaint = Paint() + ..color = const Color(0xFF3688FF) + ..style = PaintingStyle.fill; + + final path2 = Path() + ..moveTo(39.8438, 147.407) + ..lineTo(49.5455, 72.2839) + ..lineTo(0, 72.2839) + ..lineTo(0, 256.743) + ..lineTo(60.5602, 256.743) + ..lineTo(80.048, 147.407) + ..lineTo(39.8438, 147.407) + ..close(); + + canvas.drawPath(path2, bluePaint); + + final path3 = Path() + ..moveTo(192.42, 59.361) + ..cubicTo(182.782, 40.2307, 168.929, 25.5705, 150.903, 15.3381) + ..cubicTo(145.501, 12.2662, 139.761, 9.6605, 133.703, 7.5208) + ..lineTo(115.401, 103.702) + ..lineTo(159.757, 103.702) + ..lineTo(76.2987, 256.743) + ..lineTo(83.3735, 256.743) + ..cubicTo(109.449, 256.743, 131.69, 251.574, 150.14, 241.236) + ..cubicTo(168.569, 230.897, 182.634, 216.131, 192.356, 196.959) + ..cubicTo(202.058, 177.765, 206.909, 154.8, 206.909, 128.043) + ..cubicTo(206.909, 101.286, 202.079, 78.5123, 192.441, 59.3821) + ..lineTo(192.42, 59.361) + ..close(); + + canvas.drawPath(path3, bluePaint); + + canvas.restore(); + } + + @override + bool shouldRepaint(_TdMarkPainter oldDelegate) => oldDelegate.dark != dark; } diff --git a/samples/quickstart/lib/screens/home_screen.dart b/samples/quickstart/lib/screens/home_screen.dart index 241dcf1..341f05f 100644 --- a/samples/quickstart/lib/screens/home_screen.dart +++ b/samples/quickstart/lib/screens/home_screen.dart @@ -16,9 +16,10 @@ * under the License. */ +import 'dart:async'; import 'dart:convert'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:thunderid_flutter/thunderid_flutter.dart'; class HomeScreen extends StatefulWidget { @@ -29,657 +30,648 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - int _navIndex = 0; + String _screen = 'home'; @override - void initState() { - super.initState(); - if (kDebugMode) { - Future.microtask(_logToken); + Widget build(BuildContext context) { + switch (_screen) { + case 'profile': + return _ProfileScreen(onBack: () => setState(() => _screen = 'home')); + case 'token': + return _TokenScreen(onBack: () => setState(() => _screen = 'home')); + default: + return _HomeTabScreen( + onNavigate: (screen) => setState(() => _screen = screen), + ); } } +} - Future _logToken() async { - final thunder = ThunderIDProvider.of(context); - try { - final token = await thunder.client.getAccessToken(); - debugPrint('[HomeScreen] access token: $token'); - debugPrint('[HomeScreen] token payload: ${_decodeJwtPayload(token)}'); - } catch (e) { - debugPrint('[HomeScreen] could not get access token: $e'); - } +// ───────────────────────────────────────────────────────────────────────────── +// Design constants +// ───────────────────────────────────────────────────────────────────────────── + +const _kBlue = Color(0xFF3688FF); +const _kGreen = Color(0xFF2FBD6B); +const _kRed = Color(0xFFD95757); +const _kMuted = Color(0xFF5A7085); +const _kTextDark = Color(0xFF05213F); +const _kCodeBg = Color(0xFF0b1120); + +// ───────────────────────────────────────────────────────────────────────────── +// Home tab +// ───────────────────────────────────────────────────────────────────────────── + +/// Converts a raw claim value (typically a JSON number bridged from the +/// platform channel) into whole seconds-since-epoch, or `null` if the claim +/// is absent or not numeric. +int? _epochSecondsFromClaim(dynamic value) { + if (value is num) return value.toInt(); + return null; +} + +/// Formats a unix-seconds timestamp as a local `h:mm AM/PM` time string. +String _formatLocalTime(int? epochSeconds) { + if (epochSeconds == null) return 'β€”'; + final dt = DateTime.fromMillisecondsSinceEpoch(epochSeconds * 1000); + var hour12 = dt.hour % 12; + if (hour12 == 0) hour12 = 12; + final minute = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour >= 12 ? 'PM' : 'AM'; + return '$hour12:$minute $period'; +} + +/// Formats the number of whole seconds remaining until session expiry. +/// Returns `β€”` once the claim is unavailable or the session has expired. +String _formatCountdown(int? secondsLeft) { + if (secondsLeft == null || secondsLeft <= 0) return 'β€”'; + if (secondsLeft < 3600) { + final minutes = secondsLeft ~/ 60; + final seconds = secondsLeft % 60; + return '${minutes}m ${seconds}s'; } + final hours = secondsLeft ~/ 3600; + final minutes = (secondsLeft % 3600) ~/ 60; + return '${hours}h ${minutes}m'; +} - String _decodeJwtPayload(String token) { - try { - final parts = token.split('.'); - if (parts.length != 3) return '(not a JWT)'; - final padded = parts[1].padRight((parts[1].length + 3) ~/ 4 * 4, '='); - return utf8.decode(base64Url.decode(padded)); - } catch (e) { - return '(decode error: $e)'; - } +/// Returns a time-of-day-aware greeting, e.g. "Good afternoon, Alex.". +String _greeting(String name) { + final hour = DateTime.now().hour; + final String timeOfDay; + if (hour < 12) { + timeOfDay = 'morning'; + } else if (hour < 17) { + timeOfDay = 'afternoon'; + } else { + timeOfDay = 'evening'; } + return 'Good $timeOfDay, $name.'; +} + +class _HomeTabScreen extends StatefulWidget { + final void Function(String) onNavigate; + const _HomeTabScreen({required this.onNavigate}); @override - Widget build(BuildContext context) { - return Scaffold( - body: IndexedStack( - index: _navIndex, - children: [ - _ExploreTab(onProfileTap: () => setState(() => _navIndex = 4)), - const _PlaceholderTab(label: 'Saved', icon: Icons.favorite_outline), - const _PlaceholderTab(label: 'Trips', icon: Icons.card_travel_outlined), - const _PlaceholderTab(label: 'Inbox', icon: Icons.chat_bubble_outline), - const _ProfileTab(), - ], - ), - bottomNavigationBar: NavigationBar( - selectedIndex: _navIndex, - onDestinationSelected: (i) => setState(() => _navIndex = i), - labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.explore_outlined), - selectedIcon: Icon(Icons.explore), - label: 'Explore', - ), - NavigationDestination( - icon: Icon(Icons.favorite_outline), - selectedIcon: Icon(Icons.favorite), - label: 'Saved', - ), - NavigationDestination( - icon: Icon(Icons.card_travel_outlined), - selectedIcon: Icon(Icons.card_travel), - label: 'Trips', - ), - NavigationDestination( - icon: Icon(Icons.chat_bubble_outline), - selectedIcon: Icon(Icons.chat_bubble), - label: 'Inbox', - ), - NavigationDestination( - icon: Icon(Icons.person_outline), - selectedIcon: Icon(Icons.person), - label: 'Profile', - ), - ], - ), - ); - } + State<_HomeTabScreen> createState() => _HomeTabScreenState(); } -// ───────────────────────────────────────────────────────────────────────────── -// Explore tab -// ───────────────────────────────────────────────────────────────────────────── +class _HomeTabScreenState extends State<_HomeTabScreen> { + Timer? _ticker; + int _nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; -class _ExploreTab extends StatefulWidget { - final VoidCallback onProfileTap; - const _ExploreTab({required this.onProfileTap}); + @override + void initState() { + super.initState(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) { + setState(() { + _nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + }); + } + }); + } @override - State<_ExploreTab> createState() => _ExploreTabState(); -} + void dispose() { + _ticker?.cancel(); + super.dispose(); + } -class _ExploreTabState extends State<_ExploreTab> { - int _categoryIndex = 0; - int _sortIndex = 0; - - static const _categories = ['Stays', 'Experiences', 'Adventures', 'Luxe']; - static const _sorts = ['Popular', 'Near', 'Best Price']; - static const _categoryIcons = [ - Icons.home_outlined, - Icons.tour_outlined, - Icons.terrain, - Icons.diamond_outlined, - ]; - - static const _listings = [ - _Listing( - title: 'Cozy Mountain Retreat', - location: 'Aspen, Colorado', - price: 189, - rating: 4.92, - imageUrl: 'https://picsum.photos/seed/acme1/400/280', - ), - _Listing( - title: 'Beachfront Villa', - location: 'Malibu, California', - price: 342, - rating: 4.87, - imageUrl: 'https://picsum.photos/seed/acme2/400/280', - ), - _Listing( - title: 'City Centre Loft', - location: 'New York, NY', - price: 215, - rating: 4.78, - imageUrl: 'https://picsum.photos/seed/acme3/400/280', - ), - _Listing( - title: 'Lakeside Cabin', - location: 'Lake Tahoe, Nevada', - price: 156, - rating: 4.95, - imageUrl: 'https://picsum.photos/seed/acme4/400/280', - ), - ]; + static String _initials(String? displayName) { + if (displayName == null || displayName.isEmpty) return '?'; + final parts = displayName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2) { + return '${parts.first[0]}${parts.last[0]}'.toUpperCase(); + } + return displayName[0].toUpperCase(); + } @override Widget build(BuildContext context) { final thunder = ThunderIDProvider.of(context); final user = thunder.user; - final firstName = (user?.claims?['given_name'] as String?)?.isNotEmpty == true - ? user!.claims!['given_name'] as String - : user?.displayName?.split(' ').first ?? 'there'; - final cs = Theme.of(context).colorScheme; - - return SafeArea( - child: CustomScrollView( - slivers: [ - // ── Top bar ────────────────────────────────────────────────────── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 20, 16, 0), - child: Row( - children: [ - Icon(Icons.home_filled, color: cs.primary, size: 26), - const SizedBox(width: 6), - Text( - 'ACME Booking', - style: TextStyle( - color: cs.primary, - fontWeight: FontWeight.bold, - fontSize: 17, + final displayName = user?.displayName; + final greetingName = + (displayName == null || displayName.isEmpty) ? 'Guest' : displayName; + final email = user?.email ?? ''; + final initials = _initials(displayName); + + final claims = user?.claims; + final authTime = _epochSecondsFromClaim(claims?['auth_time']); + final exp = _epochSecondsFromClaim(claims?['exp']); + final secondsLeft = exp != null ? exp - _nowSeconds : null; + final organisation = thunder.widget.config.organizationHandle ?? 'Default'; + + return Scaffold( + body: SingleChildScrollView( + padding: + const EdgeInsets.only(top: 72, left: 20, right: 20, bottom: 48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── User identity ────────────────────────────────────────────── + Row( + children: [ + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: _kBlue, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + initials, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 18, + ), ), ), - const Spacer(), - GestureDetector( - onTap: widget.onProfileTap, - child: _UserAvatar(user: user, radius: 18), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _greeting(greetingName), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _kTextDark, + ), + ), + const SizedBox(height: 2), + Text( + email, + style: const TextStyle( + fontSize: 13, + color: _kMuted, + ), + ), + ], ), - const SizedBox(width: 8), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: _kGreen, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + 'Session active', + style: TextStyle( + fontSize: 13, + color: _kGreen, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + + const SizedBox(height: 20), + const Divider(), + const SizedBox(height: 16), + + // ── Stats row ────────────────────────────────────────────────── + IntrinsicHeight( + child: Row( + children: [ + _StatCell( + label: 'Signed in at', value: _formatLocalTime(authTime)), + const VerticalDivider(width: 32), + _StatCell( + label: 'Session expires in', + value: _formatCountdown(secondsLeft)), + const VerticalDivider(width: 32), + _StatCell(label: 'Organisation', value: organisation), ], ), ), - ), - // ── Welcome heading ───────────────────────────────────────────── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 0), - child: Text( - 'Where Would you\nLike to Stay, $firstName?', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + const SizedBox(height: 28), + + // ── What's next ──────────────────────────────────────────────── + Text( + 'WHAT\'S NEXT', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), - ), + const SizedBox(height: 4), - // ── Search bar ────────────────────────────────────────────────── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), - child: TextField( - decoration: InputDecoration( - hintText: 'Search destinations...', - prefixIcon: const Icon(Icons.search), - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(30), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(30), - borderSide: BorderSide.none, - ), - contentPadding: EdgeInsets.zero, - ), - ), + const _StepRow( + number: '01', + title: 'Secure your API', + subtitle: 'Add token validation to your backend.', + ), + const _StepRow( + number: '02', + title: 'Add social login', + subtitle: 'GitHub, Google, and OIDC providers.', + ), + const _StepRow( + number: '03', + title: 'Enable MFA', + subtitle: 'TOTP and passkey support.', + ), + const _StepRow( + number: '04', + title: 'Explore the SDK', + subtitle: 'API reference and guides.', ), - ), - // ── Category chips ────────────────────────────────────────────── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 20, 0, 0), - child: SizedBox( - height: 84, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _categories.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (ctx, i) { - final selected = i == _categoryIndex; - return GestureDetector( - onTap: () => setState(() => _categoryIndex = i), - child: Column( - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 56, - height: 56, - decoration: BoxDecoration( - color: selected - ? cs.primary - : cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(14), - ), - child: Icon( - _categoryIcons[i], - color: selected ? cs.onPrimary : cs.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - Text( - _categories[i], - style: TextStyle( - fontSize: 11, - fontWeight: selected - ? FontWeight.bold - : FontWeight.normal, - color: selected ? cs.primary : cs.onSurfaceVariant, - ), - ), - ], - ), - ); - }, - ), - ), + const SizedBox(height: 8), + + // ── Action list ──────────────────────────────────────────────── + const Divider(), + _ActionRow( + icon: Icons.person_outline, + label: 'My profile', + onTap: () => widget.onNavigate('profile'), ), - ), + const Divider(), + _ActionRow( + icon: Icons.key_outlined, + label: 'Token debug', + onTap: () => widget.onNavigate('token'), + ), + const Divider(), + _ActionRow( + icon: Icons.settings_outlined, + label: 'Settings', + onTap: () {}, + ), + const Divider(), - // ── Sort tabs ─────────────────────────────────────────────────── - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 0), - child: Row( - children: [ - ..._sorts.asMap().entries.map((e) { - final selected = e.key == _sortIndex; - return Padding( - padding: EdgeInsets.only( - right: e.key < _sorts.length - 1 ? 20 : 0), - child: GestureDetector( - onTap: () => setState(() => _sortIndex = e.key), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - e.value, - style: TextStyle( - fontSize: 15, - fontWeight: selected - ? FontWeight.bold - : FontWeight.normal, - color: selected - ? cs.onSurface - : cs.onSurfaceVariant, + // ── Sign out ─────────────────────────────────────────────────── + BaseSignOutButton( + builder: (ctx, isLoading) => InkWell( + onTap: null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + children: [ + isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: _kRed, ), - ), - const SizedBox(height: 3), - if (selected) - Container( - height: 2, - width: 28, - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(1), - ), - ) - else - const SizedBox(height: 2), - ], + ) + : const Icon(Icons.logout, color: _kRed, size: 20), + const SizedBox(width: 14), + const Text( + 'Sign out', + style: TextStyle( + fontSize: 15, + color: _kRed, + fontWeight: FontWeight.w500, ), ), - ); - }), - const Spacer(), - Text( - 'See More', - style: TextStyle( - color: cs.primary, - fontWeight: FontWeight.w600, - fontSize: 14, - ), + ], ), - ], + ), ), ), - ), + const Divider(), + ], + ), + ), + ); + } +} - // ── Listings grid ─────────────────────────────────────────────── - SliverPadding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), - sliver: SliverGrid.count( - crossAxisCount: 2, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 0.7, - children: - _listings.map((l) => _ListingCard(listing: l)).toList(), +class _StatCell extends StatelessWidget { + final String label; + final String value; + const _StatCell({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: _kTextDark, ), ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle(fontSize: 12, color: _kMuted), + ), ], ), ); } } -// ───────────────────────────────────────────────────────────────────────────── -// Profile tab -// ───────────────────────────────────────────────────────────────────────────── - -class _ProfileTab extends StatelessWidget { - const _ProfileTab(); +class _StepRow extends StatelessWidget { + final String number; + final String title; + final String subtitle; + const _StepRow({ + required this.number, + required this.title, + required this.subtitle, + }); @override Widget build(BuildContext context) { - final thunder = ThunderIDProvider.of(context); - final user = thunder.user; - final cs = Theme.of(context).colorScheme; - - final givenName = user?.claims?['given_name'] as String? ?? ''; - final familyName = user?.claims?['family_name'] as String? ?? ''; - final fullName = [givenName, familyName] - .where((s) => s.isNotEmpty) - .join(' ') - .trim(); - final displayName = - fullName.isNotEmpty ? fullName : (user?.username ?? 'Guest'); - - return SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Header ──────────────────────────────────────────────────── - Row( - children: [ - Text( - 'Profile', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.bold), + return Column( + children: [ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Text( + number, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: _kBlue, + fontFamily: 'monospace', ), - const Spacer(), - IconButton( - icon: const Icon(Icons.notifications_outlined), - onPressed: () {}, - ), - ], - ), - const SizedBox(height: 20), - - // ── Profile card ────────────────────────────────────────────── - Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide(color: cs.outlineVariant), ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Row( + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Stack( - children: [ - _UserAvatar(user: user, radius: 40), - Positioned( - bottom: 0, - right: 0, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - ), - child: Icon( - Icons.verified, - color: cs.onPrimary, - size: 14, - ), - ), - ), - ], - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayName, - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - 'Los Angeles, CA', - style: - TextStyle(color: cs.onSurfaceVariant), - ), - ], + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: _kTextDark, ), ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle(fontSize: 13, color: _kMuted), + ), ], ), ), - ), - const SizedBox(height: 12), - - // ── Stats card ──────────────────────────────────────────────── - Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - side: BorderSide(color: cs.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Row( - children: [ - const _StatItem(value: '24', label: 'Trips'), - _VerticalDivider(), - const _StatItem(value: '22', label: 'Reviews'), - _VerticalDivider(), - const _StatItem(value: '2', label: 'Years on ACME'), - ], - ), - ), - ), - const SizedBox(height: 12), + const Icon(Icons.chevron_right, color: _kMuted, size: 20), + ], + ), + ), + ], + ); + } +} - // ── Feature cards ───────────────────────────────────────────── - const Row( - children: [ - Expanded( - child: _FeatureCard( - label: 'Past trips', - imageUrl: 'https://picsum.photos/seed/trips/300/200', - isNew: true, - ), - ), - SizedBox(width: 12), - Expanded( - child: _FeatureCard( - label: 'Connections', - imageUrl: 'https://picsum.photos/seed/connect/300/200', - isNew: true, - ), - ), - ], - ), - const SizedBox(height: 24), - - // ── Edit profile ────────────────────────────────────────────── - OutlinedButton.icon( - onPressed: () => _showEditProfile(context), - icon: const Icon(Icons.edit_outlined), - label: const Text('Edit Profile'), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), - const SizedBox(height: 12), +class _ActionRow extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + const _ActionRow({ + required this.icon, + required this.label, + required this.onTap, + }); - // ── Sign out ────────────────────────────────────────────────── - BaseSignOutButton( - builder: (ctx, isLoading) => OutlinedButton.icon( - onPressed: null, - icon: isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.logout), - label: const Text('Sign Out'), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(48), + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + children: [ + Icon(icon, size: 20, color: _kMuted), + const SizedBox(width: 14), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 15, + color: _kTextDark, + fontWeight: FontWeight.w500, ), ), ), + const Icon(Icons.chevron_right, color: _kMuted, size: 20), ], ), ), ); } - - void _showEditProfile(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (_) => DraggableScrollableSheet( - initialChildSize: 0.85, - expand: false, - builder: (ctx, controller) => SingleChildScrollView( - controller: controller, - padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), - child: UserProfile( - onSaved: () => Navigator.pop(ctx), - ), - ), - ), - ); - } } // ───────────────────────────────────────────────────────────────────────────── -// Placeholder tab +// Profile screen // ───────────────────────────────────────────────────────────────────────────── -class _PlaceholderTab extends StatelessWidget { - final String label; - final IconData icon; - const _PlaceholderTab({required this.label, required this.icon}); +class _ProfileScreen extends StatelessWidget { + final VoidCallback onBack; + const _ProfileScreen({required this.onBack}); @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return SafeArea( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 52, color: cs.onSurfaceVariant), - const SizedBox(height: 16), - Text(label, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - Text( - 'Coming soon', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: cs.onSurfaceVariant), - ), - ], + final thunder = ThunderIDProvider.of(context); + final user = thunder.user; + final displayName = user?.displayName ?? 'Guest'; + final email = user?.email ?? ''; + final userId = user?.sub ?? 'β€”'; + final username = user?.username ?? 'β€”'; + + final initials = displayName.isNotEmpty + ? displayName + .trim() + .split(RegExp(r'\s+')) + .take(2) + .map((p) => p[0]) + .join() + .toUpperCase() + : '?'; + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Back button ────────────────────────────────────────────── + const SizedBox(height: 16), + GestureDetector( + onTap: onBack, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chevron_left, color: _kBlue, size: 22), + Text( + 'Home', + style: TextStyle( + color: _kBlue, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // ── Avatar + name + email ──────────────────────────────────── + Center( + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: const BoxDecoration( + color: _kBlue, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + initials, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ), + ), + const SizedBox(height: 12), + Text( + displayName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _kTextDark, + ), + ), + const SizedBox(height: 4), + Text( + email, + style: const TextStyle(fontSize: 13, color: _kMuted), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: _kGreen.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'Email verified', + style: TextStyle( + fontSize: 12, + color: _kGreen, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 28), + + // ── Account details section ────────────────────────────────── + const _SectionHeader(label: 'ACCOUNT DETAILS'), + const SizedBox(height: 8), + _InfoRow(label: 'USER ID', value: userId, monospace: true), + const Divider(), + _InfoRow(label: 'USERNAME', value: username), + + const SizedBox(height: 32), + ], + ), ), ), ); } } -// ───────────────────────────────────────────────────────────────────────────── -// Shared widgets -// ───────────────────────────────────────────────────────────────────────────── - -class _UserAvatar extends StatelessWidget { - final dynamic user; - final double radius; - const _UserAvatar({this.user, this.radius = 20}); +class _SectionHeader extends StatelessWidget { + final String label; + const _SectionHeader({required this.label}); @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final pictureUrl = (user?.profilePicture as String?)?.isNotEmpty == true - ? user!.profilePicture as String - : (user?.claims?['picture'] as String?)?.isNotEmpty == true - ? user!.claims!['picture'] as String - : null; - if (pictureUrl != null) { - return CircleAvatar( - radius: radius, - backgroundImage: NetworkImage(pictureUrl), - onBackgroundImageError: (_, __) {}, - backgroundColor: cs.primaryContainer, - ); - } - final displayName = user?.displayName as String?; - final email = user?.email as String?; - final initial = displayName?.isNotEmpty == true - ? displayName![0].toUpperCase() - : (email?.isNotEmpty == true ? email![0].toUpperCase() : '?'); - return CircleAvatar( - radius: radius, - backgroundColor: cs.primaryContainer, - child: Text( - initial, - style: TextStyle( - color: cs.onPrimaryContainer, - fontWeight: FontWeight.bold, - fontSize: radius * 0.9, - ), + return Text( + label, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: _kMuted, ), ); } } -class _StatItem extends StatelessWidget { - final String value; +class _InfoRow extends StatelessWidget { final String label; - const _StatItem({required this.value, required this.label}); + final String value; + final bool monospace; + const _InfoRow({ + required this.label, + required this.value, + this.monospace = false, + }); @override Widget build(BuildContext context) { - return Expanded( - child: Column( + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - value, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.bold), + SizedBox( + width: 110, + child: Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: _kMuted, + letterSpacing: 0.3, + ), + ), ), - const SizedBox(height: 4), - Text( - label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: monospace ? 12 : 14, + color: _kTextDark, + fontFamily: monospace ? 'monospace' : null, + ), + ), ), ], ), @@ -687,192 +679,403 @@ class _StatItem extends StatelessWidget { } } -class _VerticalDivider extends StatelessWidget { +// ───────────────────────────────────────────────────────────────────────────── +// Token debug screen +// ───────────────────────────────────────────────────────────────────────────── + +class _TokenScreen extends StatefulWidget { + final VoidCallback onBack; + const _TokenScreen({required this.onBack}); + @override - Widget build(BuildContext context) { - return Container( - height: 44, - width: 1, - color: Theme.of(context).colorScheme.outlineVariant, - ); - } + State<_TokenScreen> createState() => _TokenScreenState(); } -class _FeatureCard extends StatelessWidget { - final String label; - final String imageUrl; - final bool isNew; - const _FeatureCard( - {required this.label, required this.imageUrl, this.isNew = false}); +class _TokenScreenState extends State<_TokenScreen> { + String? _token; + String _payload = '{}'; + bool _loading = true; + String? _error; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_loading) { + _loadToken(); + } + } + + Future _loadToken() async { + final thunder = ThunderIDProvider.of(context); + try { + final tok = await thunder.client.getAccessToken(); + if (!mounted) return; + setState(() { + _token = tok; + _payload = _decodePayload(tok); + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + String _decodePayload(String token) { + try { + final parts = token.split('.'); + if (parts.length != 3) return '{}'; + final padded = parts[1].padRight((parts[1].length + 3) ~/ 4 * 4, '='); + return utf8.decode(base64Url.decode(padded)); + } catch (_) { + return '{}'; + } + } + + Widget _buildExpiryBadge() { + try { + final map = jsonDecode(_payload) as Map; + final exp = map['exp']; + if (exp == null) return const SizedBox.shrink(); + final expSecs = (exp as num).toInt(); + final nowSecs = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final diffSecs = expSecs - nowSecs; + if (diffSecs <= 0) { + return const _Badge(label: 'Expired', color: _kRed); + } + final mins = (diffSecs / 60).ceil(); + return _Badge(label: '$mins min', color: _kGreen); + } catch (_) { + return const SizedBox.shrink(); + } + } + + String _issuer() { + try { + final map = jsonDecode(_payload) as Map; + return (map['iss'] as String?) ?? 'β€”'; + } catch (_) { + return 'β€”'; + } + } + + String _scopes() { + try { + final map = jsonDecode(_payload) as Map; + final scp = map['scp'] ?? map['scope']; + if (scp == null) return 'β€”'; + if (scp is List) return scp.join(', '); + return scp.toString(); + } catch (_) { + return 'β€”'; + } + } @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Card( - elevation: 0, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: cs.outlineVariant), - ), - child: AspectRatio( - aspectRatio: 1, - child: Stack( - fit: StackFit.expand, - children: [ - Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Container(color: cs.surfaceContainerHighest), - ), - if (isNew) - Positioned( - top: 8, - left: 8, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Back button ────────────────────────────────────────────── + const SizedBox(height: 16), + GestureDetector( + onTap: widget.onBack, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chevron_left, color: _kBlue, size: 22), + Text( + 'Home', + style: TextStyle( + color: _kBlue, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // ── Title row ──────────────────────────────────────────────── + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Token debug', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: _kTextDark, + ), + ), + SizedBox(height: 2), + Text( + 'Access token and claims', + style: TextStyle(fontSize: 13, color: _kMuted), + ), + ], + ), + ), + if (!_loading && _token != null) _buildExpiryBadge(), + ], + ), + + const SizedBox(height: 20), + + if (_loading) + const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: CircularProgressIndicator(color: _kBlue), + ), + ) + else if (_error != null) + Text( + 'Error: $_error', + style: const TextStyle(color: _kRed, fontSize: 13), + ) + else ...[ + // ── JWT token display ──────────────────────────────────── + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(12), + color: _kCodeBg, + borderRadius: BorderRadius.circular(10), ), - child: Text( - 'NEW', - style: TextStyle( - color: cs.onPrimary, - fontSize: 10, - fontWeight: FontWeight.bold, + child: _token != null + ? _buildJwtRichText(_token!) + : const SizedBox.shrink(), + ), + + const SizedBox(height: 8), + + // ── Copy button ────────────────────────────────────────── + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () async { + if (_token != null) { + await Clipboard.setData(ClipboardData(text: _token!)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Token copied to clipboard'), + duration: Duration(seconds: 2), + ), + ); + } + } + }, + icon: const Icon(Icons.copy, size: 14, color: _kMuted), + label: const Text( + 'Copy', + style: TextStyle(fontSize: 13, color: _kMuted), ), ), ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [Colors.black54, Colors.transparent], + + const SizedBox(height: 16), + + // ── JWT Payload section ────────────────────────────────── + const Text( + 'JWT PAYLOAD', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: _kMuted, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _kCodeBg, + borderRadius: BorderRadius.circular(10), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + _prettyJson(_payload), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Color(0xFF79c0ff), + height: 1.5, + ), + ), ), ), - child: Text( - label, - style: const TextStyle( - color: Colors.white, + + const SizedBox(height: 20), + + // ── Metadata rows ──────────────────────────────────────── + const Text( + 'METADATA', + style: TextStyle( + fontSize: 10, fontWeight: FontWeight.bold, - fontSize: 14, + letterSpacing: 1.0, + color: _kMuted, ), ), - ), - ), - ], + const SizedBox(height: 8), + _MetaRow(label: 'Issuer', value: _issuer()), + const Divider(), + _MetaRow(label: 'Scopes', value: _scopes()), + const Divider(), + ], + + const SizedBox(height: 40), + ], + ), ), ), ); } -} -// ───────────────────────────────────────────────────────────────────────────── -// Listing data & card -// ───────────────────────────────────────────────────────────────────────────── + Widget _buildJwtRichText(String token) { + final parts = token.split('.'); + if (parts.length != 3) { + return Text( + token, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Colors.white70, + ), + ); + } + return RichText( + text: TextSpan( + children: [ + TextSpan( + text: parts[0], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Color(0xFFff7b72), + ), + ), + const TextSpan( + text: '.', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Color(0xFF8b949e), + ), + ), + TextSpan( + text: parts[1], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Color(0xFF79c0ff), + ), + ), + const TextSpan( + text: '.', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Color(0xFF8b949e), + ), + ), + TextSpan( + text: parts[2], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: Color(0xFF3fb950), + ), + ), + ], + ), + ); + } -class _Listing { - final String title; - final String location; - final int price; - final double rating; - final String imageUrl; + String _prettyJson(String raw) { + try { + final obj = jsonDecode(raw); + const encoder = JsonEncoder.withIndent(' '); + return encoder.convert(obj); + } catch (_) { + return raw; + } + } +} - const _Listing({ - required this.title, - required this.location, - required this.price, - required this.rating, - required this.imageUrl, - }); +class _Badge extends StatelessWidget { + final String label; + final Color color; + const _Badge({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } } -class _ListingCard extends StatelessWidget { - final _Listing listing; - const _ListingCard({required this.listing}); +class _MetaRow extends StatelessWidget { + final String label; + final String value; + const _MetaRow({required this.label, required this.value}); @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Card( - elevation: 0, - clipBehavior: Clip.antiAlias, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Column( + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Stack( - fit: StackFit.expand, - children: [ - Image.network( - listing.imageUrl, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Container(color: cs.surfaceContainerHighest), - ), - const Positioned( - top: 8, - right: 8, - child: Icon(Icons.favorite_outline, - color: Colors.white, size: 20), - ), - ], + SizedBox( + width: 80, + child: Text( + label, + style: const TextStyle( + fontSize: 13, + color: _kMuted, + fontWeight: FontWeight.w500, + ), ), ), - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - listing.location, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith(color: cs.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Icon(Icons.star, size: 11, color: cs.primary), - const SizedBox(width: 2), - Text( - listing.rating.toStringAsFixed(2), - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), - const SizedBox(height: 2), - Text( - listing.title, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(fontWeight: FontWeight.w600), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - '\$${listing.price}/night', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 13, + color: _kTextDark, + ), ), ), ], diff --git a/samples/quickstart/lib/screens/profile_screen.dart b/samples/quickstart/lib/screens/profile_screen.dart index dfc5bd8..6235e1c 100644 --- a/samples/quickstart/lib/screens/profile_screen.dart +++ b/samples/quickstart/lib/screens/profile_screen.dart @@ -32,7 +32,7 @@ class ProfileScreen extends StatelessWidget { body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(24), - child: ThunderIDUserProfile( + child: UserProfile( onSaved: () => ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Profile saved')), ), diff --git a/samples/quickstart/pubspec.yaml b/samples/quickstart/pubspec.yaml index 95f68fd..8b7f425 100644 --- a/samples/quickstart/pubspec.yaml +++ b/samples/quickstart/pubspec.yaml @@ -1,6 +1,7 @@ name: flutter_quickstart description: ThunderID Flutter B2C sample β€” demonstrates sign-in, profile display, and sign-out. version: 1.0.0 +publish_to: none environment: sdk: ">=3.2.0 <4.0.0" diff --git a/samples/quickstart/server.cert b/samples/quickstart/server.cert deleted file mode 100644 index eda67aa..0000000 --- a/samples/quickstart/server.cert +++ /dev/null @@ -1,20 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDSzCCAjOgAwIBAgIUJBjTCM3ahKdobqbPg4IxPoRiOAQwDQYJKoZIhvcNAQEL -BQAwNTENMAsGA1UECgwEV1NPMjEQMA4GA1UECwwHVGh1bmRlcjESMBAGA1UEAwwJ -bG9jYWxob3N0MB4XDTI2MDIwNTA5NTQyOFoXDTI3MDIwNTA5NTQyOFowNTENMAsG -A1UECgwEV1NPMjEQMA4GA1UECwwHVGh1bmRlcjESMBAGA1UEAwwJbG9jYWxob3N0 -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAntgmGMU0jT0WQ5hLi49O -HTWkPmGypi0JeTJE75ZWoVQlE/4tfCO7208nyxUgnsGavcO38s0eks784FiW0lsY -fiu8YwIHCnvjOp9wWT0rsNk8EuE9N0hPyLzaoSstynn2vJLhmjCR9d9KBXx9SwGc -e1lu+7fpDPwNm6iuY3+OiTi1+OrRfWSOkoRfNTM9v2KEsBpQDd/uP94idrMs8EPc -FOAjxtiZdbAisMOKT4xtDfbVo8Q7miFrK1Cc+rEnSRjqF0iKIHEWwq6f1nUQBHQO -xLQyj04sMRbzGhAKcUU82J06LOS+GTlogH5K6vETFDQHemafjcyzgulseKGF1jjm -8wIDAQABo1MwUTAdBgNVHQ4EFgQUSvngE+d4KVZEI0dnJ2rBRC7IH54wHwYDVR0j -BBgwFoAUSvngE+d4KVZEI0dnJ2rBRC7IH54wDwYDVR0TAQH/BAUwAwEB/zANBgkq -hkiG9w0BAQsFAAOCAQEAIaSinXnOmA6Bo7ZOOB4ZRHkkfabpH3/2uW+YEH+Ni/8O -51QPKa72LQmAdm19msZrl30nRKrZBzfJoRLzyl9JwgHv0IM696/rXppxrvmoSVzU -rKWUfN24pAnD8OMuiJmweIcAQRYAgeN2cT36WECgGdJ44ljN4NtPO5CcvyUMrIkv -YeaFjz9M2N+D2zdebSsebJAz7ytI8rJd0Ts5P9LljBRYwX7ewMH9L0zzJICZwVBS -K5EZFWebEpdUEbRjxsxWOXtY8VcesrB7YnTN4GYOHFRCOW8HAEd4p0NiwFD93Zgp -1nUvjbMbSoKyYklTZ5y68YcrfxVe+lDU3LuHJxfOQw== ------END CERTIFICATE----- diff --git a/samples/quickstart/server.key b/samples/quickstart/server.key deleted file mode 100644 index 6db1b63..0000000 --- a/samples/quickstart/server.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCe2CYYxTSNPRZD -mEuLj04dNaQ+YbKmLQl5MkTvllahVCUT/i18I7vbTyfLFSCewZq9w7fyzR6Szvzg -WJbSWxh+K7xjAgcKe+M6n3BZPSuw2TwS4T03SE/IvNqhKy3Kefa8kuGaMJH130oF -fH1LAZx7WW77t+kM/A2bqK5jf46JOLX46tF9ZI6ShF81Mz2/YoSwGlAN3+4/3iJ2 -syzwQ9wU4CPG2Jl1sCKww4pPjG0N9tWjxDuaIWsrUJz6sSdJGOoXSIogcRbCrp/W -dRAEdA7EtDKPTiwxFvMaEApxRTzYnTos5L4ZOWiAfkrq8RMUNAd6Zp+NzLOC6Wx4 -oYXWOObzAgMBAAECggEAR0oRBwryw/THmjyuowsE7t0VQp4MVMJfvxINmE2rwaoH -Hgr0lQNgm1PSMzXfNwEDFunRE9p1P0RAf9uijPQvcOjmGrVdPZpOaBAIlzR5+Fv2 -ty55j+cXT6tan/zGB1pnn0h4kf/8chHtobq2bMRjW4TO55ApuxcgcFzrL2KfMPwa -cLfNFGmKIg37fYdxAgs4QuBeMxCcnAXvMkeyHRoDUU3JIvUn0rFsq7K5EHzsdlml -1f7DbzMc7v/JyfGi126eHZVgHKPGXJT8lQXU1S04bTp/KPq0UJ2DD4Gv2SqUxXs5 -jFlkSvxqW/zNcM6JoSlnkA8CN1yaTFijlVQ9Ud0W0QKBgQDWPspo5bEoa531P+Ua -5/x+sj/KZsjvpXlI1Bk9MUqAEdb0GjeaybYVaOl/XJGNVRQIMlsdsYjAqt0fJ8yY -fAzUcDUt6etfv7ExNx/SasIiQHn5UuSbA0CAHdCDy4QVyARDiLcOoKkio7k7rAPx -OVFSTtNOhZqXXkNu/pEs0Gdc6QKBgQC9zUXB83C96gsRoRkgVJ2g1k65QLXqwxHr -hO567XWFlhvnZs5IH4G0HGHh6OAKgcPOp+qP72pM9EFi8wXeVY0PGNhjFHu/zJtM -FhcoqwYDXkwzsAhaiOw4QwLJ9nExXpCQbfbLcylA8Ppq9Zto92GH31pOKlDI1RkE -mvlNtz9LewKBgQCZ/Id+3Zh3dOITY9lHfXX9miA/WaESndyOe+OoF8V0R144FJ/i -oRg+SymIYQA8lOnQ+mCC9xdTY3uK6RAkiQSvq/aNCZQjPaQdu4Eju5fNBoqkqO+m -eq/hZYx5Lc3B2gE2iQ0JdnAadZKtUPWEa64HeqBlz6FNKnNNU6sEisXAAQKBgG5D -Z2gifffGlMGcHlW68ovylG9o2YZV5v41DrpZYzHFH9bR0bryms5AwtiUCjvwYQmY -YVtXEiE8sRPD7T/Z4YXIwETnLHEjc4DDQJb+rBKe4JQSivfVna2duGwOAHpW3pMZ -XwuYjnnMxR4IK03SkVV2+yZ3zNCvg8Y6JeTxNRMrAoGAehl4mbDLlq46g33nQaoJ -mywTtzA3d9et4VhzNp/WCr1Hu4leea7rvQj4LUOlvDeweKPJth3Z/6JKRClc/sAH -CCe3kqnaKEVEc+l0KjJFD8vjW6to2RTfWwCkSFS+CsYgOzlhbessuyJWUoZM9vWN -km/bIBWNTEc12Po5Ae+GDFM= ------END PRIVATE KEY----- diff --git a/test/thunderid_client_test.dart b/test/thunderid_client_test.dart index 928d6a0..7d562c0 100644 --- a/test/thunderid_client_test.dart +++ b/test/thunderid_client_test.dart @@ -16,12 +16,11 @@ * under the License. */ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:thunderid_flutter/src/thunderid_client.dart'; import 'package:thunderid_flutter/src/models/thunderid_config.dart'; import 'package:thunderid_flutter/src/models/thunderid_error.dart'; -import 'package:thunderid_flutter/src/channel/thunderid_channel.dart'; -import 'package:flutter/services.dart'; +import 'package:thunderid_flutter/src/thunderid_client.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -106,7 +105,7 @@ void main() { }); test('IAMException toString includes code', () { - final e = IAMException(ThunderIDErrorCode.networkError, 'connection refused'); + const e = IAMException(ThunderIDErrorCode.networkError, 'connection refused'); expect(e.toString(), contains('NETWORK_ERROR')); }); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index f0424ef..aaab0e8 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -16,38 +16,30 @@ * under the License. */ +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/services.dart'; -import 'package:thunderid_flutter/src/thunderid_client.dart'; import 'package:thunderid_flutter/src/models/thunderid_config.dart'; import 'package:thunderid_flutter/src/models/user.dart'; -import 'package:thunderid_flutter/src/models/organization.dart'; +import 'package:thunderid_flutter/src/thunderid_client.dart'; +import 'package:thunderid_flutter/src/widgets/language_switcher.dart'; +import 'package:thunderid_flutter/src/widgets/loading.dart'; +import 'package:thunderid_flutter/src/widgets/signed_in.dart'; +import 'package:thunderid_flutter/src/widgets/signed_out.dart'; import 'package:thunderid_flutter/src/widgets/thunderid_provider.dart'; -import 'package:thunderid_flutter/src/widgets/thunderid_signed_in.dart'; -import 'package:thunderid_flutter/src/widgets/thunderid_signed_out.dart'; -import 'package:thunderid_flutter/src/widgets/thunderid_loading.dart'; -import 'package:thunderid_flutter/src/widgets/thunderid_user.dart'; -import 'package:thunderid_flutter/src/widgets/thunderid_organization_list.dart'; -import 'package:thunderid_flutter/src/widgets/thunderid_organization_switcher.dart'; -import 'package:thunderid_flutter/src/widgets/thunderid_language_switcher.dart'; +import 'package:thunderid_flutter/src/widgets/user_object.dart'; // ── Test helpers ────────────────────────────────────────────────────────────── const _config = ThunderIDConfig(baseUrl: 'https://localhost:8090', clientId: 'test'); -final _mockUser = User( +const _mockUser = User( sub: 'u1', username: 'alice', email: 'alice@example.com', displayName: 'Alice Doe', ); -final _mockOrgs = [ - Organization(id: 'o1', name: 'Org One', handle: 'org-one'), - Organization(id: 'o2', name: 'Org Two', handle: 'org-two'), -]; - /// Sets up a mock MethodChannel for dev.thunderid/sdk. void _setHandler(MethodChannel channel, Future Function(MethodCall) handler) { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -74,12 +66,6 @@ Widget _providerWidget({ return signedIn; case 'getUser': return signedIn ? _mockUser.toMap() : null; - case 'getMyOrganizations': - return _mockOrgs.map((o) => o.toMap()).toList(); - case 'getCurrentOrganization': - return _mockOrgs.first.toMap(); - case 'switchOrganization': - return {'accessToken': 'tok', 'refreshToken': 'ref', 'idToken': 'id', 'expiresIn': 3600}; default: return null; } @@ -101,12 +87,12 @@ void main() { tearDown(() => _clearHandler(_sdkChannel)); - group('ThunderIDSignedIn', () { + group('SignedIn', () { testWidgets('renders child when signed in', (tester) async { await tester.pumpWidget(_providerWidget( signedIn: true, - child: const ThunderIDSignedIn(child: Text('hello', textDirection: TextDirection.ltr)), - )); + child: const SignedIn(child: Text('hello', textDirection: TextDirection.ltr)), + ),); await tester.pumpAndSettle(); expect(find.text('hello'), findsOneWidget); }); @@ -114,8 +100,8 @@ void main() { testWidgets('hides child when signed out', (tester) async { await tester.pumpWidget(_providerWidget( signedIn: false, - child: const ThunderIDSignedIn(child: Text('hello', textDirection: TextDirection.ltr)), - )); + child: const SignedIn(child: Text('hello', textDirection: TextDirection.ltr)), + ),); await tester.pumpAndSettle(); expect(find.text('hello'), findsNothing); }); @@ -123,22 +109,22 @@ void main() { testWidgets('shows fallback when signed out', (tester) async { await tester.pumpWidget(_providerWidget( signedIn: false, - child: const ThunderIDSignedIn( - child: Text('hello', textDirection: TextDirection.ltr), + child: const SignedIn( fallback: Text('sign in please', textDirection: TextDirection.ltr), + child: Text('hello', textDirection: TextDirection.ltr), ), - )); + ),); await tester.pumpAndSettle(); expect(find.text('sign in please'), findsOneWidget); }); }); - group('ThunderIDSignedOut', () { + group('SignedOut', () { testWidgets('renders child when signed out', (tester) async { await tester.pumpWidget(_providerWidget( signedIn: false, - child: const ThunderIDSignedOut(child: Text('signed out', textDirection: TextDirection.ltr)), - )); + child: const SignedOut(child: Text('signed out', textDirection: TextDirection.ltr)), + ),); await tester.pumpAndSettle(); expect(find.text('signed out'), findsOneWidget); }); @@ -146,22 +132,22 @@ void main() { testWidgets('hides child when signed in', (tester) async { await tester.pumpWidget(_providerWidget( signedIn: true, - child: const ThunderIDSignedOut(child: Text('signed out', textDirection: TextDirection.ltr)), - )); + child: const SignedOut(child: Text('signed out', textDirection: TextDirection.ltr)), + ),); await tester.pumpAndSettle(); expect(find.text('signed out'), findsNothing); }); }); - group('ThunderIDLoading', () { + group('Loading', () { testWidgets('renders indicator while loading', (tester) async { // Loading state only exists transiently during init; pump once to catch it. await tester.pumpWidget(_providerWidget( signedIn: false, - child: ThunderIDLoading( - indicator: const Text('loading...', textDirection: TextDirection.ltr), + child: const Loading( + indicator: Text('loading...', textDirection: TextDirection.ltr), ), - )); + ),); // First pump shows loading state before async init completes. expect(find.text('loading...'), findsOneWidget); await tester.pumpAndSettle(); @@ -170,89 +156,45 @@ void main() { }); }); - group('ThunderIDUser', () { - testWidgets('BaseThunderIDUser receives signed-in user', (tester) async { + group('UserObject', () { + testWidgets('BaseUserObject receives signed-in user', (tester) async { String? displayedName; await tester.pumpWidget(_providerWidget( signedIn: true, - child: BaseThunderIDUser( + child: BaseUserObject( builder: (_, user) { displayedName = user?.displayName; return const SizedBox(); }, ), - )); + ),); await tester.pumpAndSettle(); expect(displayedName, 'Alice Doe'); }); - testWidgets('BaseThunderIDUser receives null when signed out', (tester) async { - User? capturedUser = User(sub: 'placeholder', username: 'x'); + testWidgets('BaseUserObject receives null when signed out', (tester) async { + User? capturedUser = const User(sub: 'placeholder', username: 'x'); await tester.pumpWidget(_providerWidget( signedIn: false, - child: BaseThunderIDUser( + child: BaseUserObject( builder: (_, user) { capturedUser = user; return const SizedBox(); }, ), - )); + ),); await tester.pumpAndSettle(); expect(capturedUser, isNull); }); }); - group('ThunderIDOrganizationList', () { - testWidgets('BaseThunderIDOrganizationList fetches and exposes org list', (tester) async { - List? captured; - await tester.pumpWidget(_providerWidget( - signedIn: true, - child: BaseThunderIDOrganizationList( - builder: (_, orgs, isLoading, error) { - if (!isLoading) captured = orgs; - return const SizedBox(); - }, - ), - )); - await tester.pumpAndSettle(); - expect(captured, isNotNull); - expect(captured!.length, 2); - expect(captured!.first.name, 'Org One'); - }); - }); - - group('ThunderIDOrganizationSwitcher', () { - testWidgets('BaseThunderIDOrganizationSwitcher calls switchOrganization on select', (tester) async { - Organization? switched; - await tester.pumpWidget(_providerWidget( - signedIn: true, - child: BaseThunderIDOrganizationSwitcher( - builder: (_, orgs, current, isSwitching, error, switchOrg) { - if (orgs.isEmpty || isSwitching) return const SizedBox(); - return GestureDetector( - onTap: () async { - await switchOrg(orgs.last); - switched = orgs.last; - }, - child: const Text('switch', textDirection: TextDirection.ltr), - ); - }, - ), - )); - await tester.pumpAndSettle(); - await tester.tap(find.text('switch')); - await tester.pumpAndSettle(); - expect(switched?.name, 'Org Two'); - }); - }); - - group('ThunderIDLanguageSwitcher', () { - testWidgets('BaseThunderIDLanguageSwitcher exposes active locale and select callback', (tester) async { + group('LanguageSwitcher', () { + testWidgets('BaseLanguageSwitcher exposes active locale and select callback', (tester) async { String? activeBefore; String? activeAfter; await tester.pumpWidget(_providerWidget( signedIn: false, - child: BaseThunderIDLanguageSwitcher( + child: BaseLanguageSwitcher( locales: const ['en-US', 'fr-FR'], builder: (_, active, select) { activeBefore ??= active; @@ -265,7 +207,7 @@ void main() { ); }, ), - )); + ),); await tester.pumpAndSettle(); expect(activeBefore, 'en-US'); await tester.tap(find.text('fr'));