added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,163 @@
// lib/ui/auth/auth_wrapper.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/ui/auth/pinepods_startup_login.dart';
import 'package:provider/provider.dart';
class AuthWrapper extends StatefulWidget {
final Widget child;
const AuthWrapper({
Key? key,
required this.child,
}) : super(key: key);
@override
State<AuthWrapper> createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
bool _hasInitializedTheme = false;
@override
Widget build(BuildContext context) {
return Consumer<SettingsBloc>(
builder: (context, settingsBloc, _) {
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: settingsBloc.currentSettings,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
final settings = snapshot.data!;
// Check if PinePods server is configured
final hasServer = settings.pinepodsServer != null &&
settings.pinepodsServer!.isNotEmpty;
final hasApiKey = settings.pinepodsApiKey != null &&
settings.pinepodsApiKey!.isNotEmpty;
if (hasServer && hasApiKey) {
// User is logged in, fetch theme from server if not already done
if (!_hasInitializedTheme) {
_hasInitializedTheme = true;
// Fetch theme from server on next frame to avoid modifying state during build
WidgetsBinding.instance.addPostFrameCallback((_) {
settingsBloc.fetchThemeFromServer();
});
}
// Show main app
return widget.child;
} else {
// User needs to login, reset theme initialization flag
_hasInitializedTheme = false;
// Show startup login
return PinepodsStartupLogin(
onLoginSuccess: () {
// Force rebuild to check auth state again
// The StreamBuilder will automatically rebuild when settings change
},
);
}
},
);
},
);
}
}
// Alternative version if you want more explicit control
class AuthChecker extends StatefulWidget {
final Widget authenticatedChild;
final Widget? unauthenticatedChild;
const AuthChecker({
Key? key,
required this.authenticatedChild,
this.unauthenticatedChild,
}) : super(key: key);
@override
State<AuthChecker> createState() => _AuthCheckerState();
}
class _AuthCheckerState extends State<AuthChecker> {
bool _isCheckingAuth = true;
bool _isAuthenticated = false;
@override
void initState() {
super.initState();
_checkAuthStatus();
}
void _checkAuthStatus() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final hasServer = settings.pinepodsServer != null &&
settings.pinepodsServer!.isNotEmpty;
final hasApiKey = settings.pinepodsApiKey != null &&
settings.pinepodsApiKey!.isNotEmpty;
setState(() {
_isAuthenticated = hasServer && hasApiKey;
_isCheckingAuth = false;
});
}
void _onLoginSuccess() {
setState(() {
_isAuthenticated = true;
});
}
@override
Widget build(BuildContext context) {
if (_isCheckingAuth) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (_isAuthenticated) {
return widget.authenticatedChild;
} else {
return widget.unauthenticatedChild ??
PinepodsStartupLogin(onLoginSuccess: _onLoginSuccess);
}
}
}
// Simple authentication status provider
class AuthStatus extends InheritedWidget {
final bool isAuthenticated;
final VoidCallback? onAuthChanged;
const AuthStatus({
Key? key,
required this.isAuthenticated,
this.onAuthChanged,
required Widget child,
}) : super(key: key, child: child);
static AuthStatus? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AuthStatus>();
}
@override
bool updateShouldNotify(AuthStatus oldWidget) {
return isAuthenticated != oldWidget.isAuthenticated;
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:pinepods_mobile/services/pinepods/oidc_service.dart';
class OidcBrowser extends StatefulWidget {
final String authUrl;
final String serverUrl;
final Function(String apiKey) onSuccess;
final Function(String error) onError;
const OidcBrowser({
super.key,
required this.authUrl,
required this.serverUrl,
required this.onSuccess,
required this.onError,
});
@override
State<OidcBrowser> createState() => _OidcBrowserState();
}
class _OidcBrowserState extends State<OidcBrowser> {
late final WebViewController _controller;
bool _isLoading = true;
String _currentUrl = '';
bool _callbackTriggered = false; // Prevent duplicate callbacks
@override
void initState() {
super.initState();
_initializeWebView();
}
void _initializeWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String url) {
setState(() {
_currentUrl = url;
_isLoading = true;
});
_checkForCallback(url);
},
onPageFinished: (String url) {
setState(() {
_isLoading = false;
});
_checkForCallback(url);
},
onNavigationRequest: (NavigationRequest request) {
_checkForCallback(request.url);
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse(widget.authUrl));
}
void _checkForCallback(String url) {
if (_callbackTriggered) return; // Prevent duplicate callbacks
// Check if we've reached the callback URL with an API key
final apiKey = OidcService.extractApiKeyFromUrl(url);
if (apiKey != null) {
_callbackTriggered = true; // Mark callback as triggered
widget.onSuccess(apiKey);
return;
}
// Check for error in callback URL
final uri = Uri.tryParse(url);
if (uri != null && uri.path.contains('/oauth/callback')) {
final error = uri.queryParameters['error'];
if (error != null) {
_callbackTriggered = true; // Mark callback as triggered
final errorDescription = uri.queryParameters['description'] ?? uri.queryParameters['details'] ?? 'Authentication failed';
widget.onError('$error: $errorDescription');
return;
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sign In'),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
widget.onError('User cancelled authentication');
},
),
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
],
),
body: Column(
children: [
// URL bar for debugging
if (MediaQuery.of(context).size.height > 600)
Container(
padding: const EdgeInsets.all(8.0),
color: Colors.grey[200],
child: Row(
children: [
const Icon(Icons.link, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
_currentUrl,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// WebView
Expanded(
child: WebViewWidget(
controller: _controller,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,777 @@
// lib/ui/auth/pinepods_startup_login.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/login_service.dart';
import 'package:pinepods_mobile/services/pinepods/oidc_service.dart';
import 'package:pinepods_mobile/services/auth_notifier.dart';
import 'package:pinepods_mobile/ui/auth/oidc_browser.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:math';
import 'dart:async';
class PinepodsStartupLogin extends StatefulWidget {
final VoidCallback? onLoginSuccess;
const PinepodsStartupLogin({
Key? key,
this.onLoginSuccess,
}) : super(key: key);
@override
State<PinepodsStartupLogin> createState() => _PinepodsStartupLoginState();
}
class _PinepodsStartupLoginState extends State<PinepodsStartupLogin> {
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _mfaController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showMfaField = false;
bool _isLoadingOidc = false;
String _errorMessage = '';
String? _tempServerUrl;
String? _tempUsername;
int? _tempUserId;
String? _tempMfaSessionToken;
List<OidcProvider> _oidcProviders = [];
bool _hasCheckedOidc = false;
Timer? _oidcCheckTimer;
// List of background images - you can add your own images to assets/images/
final List<String> _backgroundImages = [
'assets/images/1.webp',
'assets/images/2.webp',
'assets/images/3.webp',
'assets/images/4.webp',
'assets/images/5.webp',
'assets/images/6.webp',
'assets/images/7.webp',
'assets/images/8.webp',
'assets/images/9.webp',
];
late String _selectedBackground;
@override
void initState() {
super.initState();
// Select a random background image
final random = Random();
_selectedBackground = _backgroundImages[random.nextInt(_backgroundImages.length)];
// Listen for server URL changes to check OIDC providers
_serverController.addListener(_onServerUrlChanged);
// Register global login success callback
AuthNotifier.setGlobalLoginSuccessCallback(_handleLoginSuccess);
}
void _onServerUrlChanged() {
final serverUrl = _serverController.text.trim();
// Cancel any existing timer
_oidcCheckTimer?.cancel();
// Reset OIDC state
setState(() {
_oidcProviders.clear();
_hasCheckedOidc = false;
_isLoadingOidc = false;
});
// Only check if URL looks complete and valid
if (serverUrl.isNotEmpty &&
(serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) &&
_isValidUrl(serverUrl)) {
// Debounce the API call - wait 1 second after user stops typing
_oidcCheckTimer = Timer(const Duration(seconds: 1), () {
_checkOidcProviders(serverUrl);
});
}
}
bool _isValidUrl(String url) {
try {
final uri = Uri.parse(url);
// Check if it has a proper host (not just protocol)
return uri.hasScheme &&
uri.host.isNotEmpty &&
uri.host.contains('.') && // Must have at least one dot for domain
uri.host.length > 3; // Minimum reasonable length
} catch (e) {
return false;
}
}
Future<void> _checkOidcProviders(String serverUrl) async {
// Allow rechecking if server URL changed
final currentUrl = _serverController.text.trim();
if (currentUrl != serverUrl) return; // URL changed while we were waiting
setState(() {
_isLoadingOidc = true;
});
try {
final providers = await OidcService.getPublicProviders(serverUrl);
// Double-check the URL hasn't changed during the API call
if (mounted && _serverController.text.trim() == serverUrl) {
setState(() {
_oidcProviders = providers;
_hasCheckedOidc = true;
_isLoadingOidc = false;
});
}
} catch (e) {
// Only update state if URL hasn't changed
if (mounted && _serverController.text.trim() == serverUrl) {
setState(() {
_oidcProviders.clear();
_hasCheckedOidc = true;
_isLoadingOidc = false;
});
}
}
}
// Manual retry when user focuses on other fields (like username)
void _retryOidcCheck() {
final serverUrl = _serverController.text.trim();
if (serverUrl.isNotEmpty &&
_isValidUrl(serverUrl) &&
!_hasCheckedOidc &&
!_isLoadingOidc) {
_checkOidcProviders(serverUrl);
}
}
Future<void> _handleOidcLogin(OidcProvider provider) async {
final serverUrl = _serverController.text.trim();
if (serverUrl.isEmpty) {
setState(() {
_errorMessage = 'Please enter a server URL first';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Generate PKCE and state parameters for security
final pkce = OidcService.generatePkce();
final state = OidcService.generateState();
// Build authorization URL for in-app browser
final authUrl = await OidcService.buildOidcLoginUrl(
provider: provider,
serverUrl: serverUrl,
state: state,
pkce: pkce,
);
if (authUrl == null) {
setState(() {
_errorMessage = 'Failed to prepare OIDC authentication URL';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = false;
});
// Launch in-app browser
if (mounted) {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => OidcBrowser(
authUrl: authUrl,
serverUrl: serverUrl,
onSuccess: (apiKey) async {
Navigator.of(context).pop(); // Close the browser
await _completeOidcLogin(apiKey, serverUrl);
},
onError: (error) {
Navigator.of(context).pop(); // Close the browser
setState(() {
_errorMessage = 'Authentication failed: $error';
});
},
),
),
);
}
} catch (e) {
setState(() {
_errorMessage = 'OIDC login error: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _completeOidcLogin(String apiKey, String serverUrl) async {
if (!mounted) return;
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Verify API key
final isValidKey = await PinepodsLoginService.verifyApiKey(serverUrl, apiKey);
if (!isValidKey) {
throw Exception('API key verification failed');
}
// Get user ID
final userId = await PinepodsLoginService.getUserId(serverUrl, apiKey);
if (userId == null) {
throw Exception('Failed to get user ID');
}
// Get user details
final userDetails = await PinepodsLoginService.getUserDetails(serverUrl, apiKey, userId);
if (userDetails == null) {
throw Exception('Failed to get user details');
}
// Store credentials
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(serverUrl);
settingsBloc.setPinepodsApiKey(apiKey);
settingsBloc.setPinepodsUserId(userId);
// Set additional user details if available
if (userDetails.username != null) {
settingsBloc.setPinepodsUsername(userDetails.username!);
}
if (userDetails.email != null) {
settingsBloc.setPinepodsEmail(userDetails.email!);
}
// Fetch theme from server
try {
await settingsBloc.fetchThemeFromServer();
} catch (e) {
// Theme fetch failure is non-critical
}
// Notify login success
AuthNotifier.notifyLoginSuccess();
// Call the callback if provided
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Failed to complete login: ${e.toString()}';
_isLoading = false;
});
}
}
}
Future<void> _connectToPinepods() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
if (_showMfaField && _tempMfaSessionToken != null) {
// Complete MFA login flow
final mfaCode = _mfaController.text.trim();
final result = await PinepodsLoginService.completeMfaLogin(
serverUrl: _tempServerUrl!,
username: _tempUsername!,
mfaSessionToken: _tempMfaSessionToken!,
mfaCode: mfaCode,
);
if (result.isSuccess) {
// Save the connection details including user ID
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(result.serverUrl!);
settingsBloc.setPinepodsApiKey(result.apiKey!);
settingsBloc.setPinepodsUserId(result.userId!);
// Fetch theme from server after successful login
await settingsBloc.fetchThemeFromServer();
setState(() {
_isLoading = false;
});
// Call success callback
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} else {
setState(() {
_errorMessage = result.errorMessage ?? 'MFA verification failed';
_isLoading = false;
});
}
} else {
// Initial login flow
final serverUrl = _serverController.text.trim();
final username = _usernameController.text.trim();
final password = _passwordController.text;
final result = await PinepodsLoginService.login(
serverUrl,
username,
password,
);
if (result.isSuccess) {
// Save the connection details including user ID
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(result.serverUrl!);
settingsBloc.setPinepodsApiKey(result.apiKey!);
settingsBloc.setPinepodsUserId(result.userId!);
// Fetch theme from server after successful login
await settingsBloc.fetchThemeFromServer();
setState(() {
_isLoading = false;
});
// Call success callback
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} else if (result.requiresMfa) {
// Store MFA session info and show MFA field
setState(() {
_tempServerUrl = result.serverUrl;
_tempUsername = result.username;
_tempUserId = result.userId;
_tempMfaSessionToken = result.mfaSessionToken;
_showMfaField = true;
_isLoading = false;
_errorMessage = 'Please enter your MFA code';
});
} else {
setState(() {
_errorMessage = result.errorMessage ?? 'Login failed';
_isLoading = false;
});
}
}
} catch (e) {
setState(() {
_errorMessage = 'Error: ${e.toString()}';
_isLoading = false;
});
}
}
void _resetMfa() {
setState(() {
_showMfaField = false;
_tempServerUrl = null;
_tempUsername = null;
_tempUserId = null;
_tempMfaSessionToken = null;
_mfaController.clear();
_errorMessage = '';
});
}
/// Parse hex color string to Color object
Color _parseColor(String hexColor) {
try {
final hex = hexColor.replaceAll('#', '');
if (hex.length == 6) {
return Color(int.parse('FF$hex', radix: 16));
} else if (hex.length == 8) {
return Color(int.parse(hex, radix: 16));
}
} catch (e) {
// Fallback to default color on parsing error
}
return Theme.of(context).primaryColor;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(_selectedBackground),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.6),
BlendMode.darken,
),
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App Logo/Title
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/images/favicon.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.headset,
size: 48,
color: Colors.white,
),
);
},
),
),
),
),
const SizedBox(height: 16),
Text(
'Welcome to PinePods',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Connect to your PinePods server to get started',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Server URL Field
TextFormField(
controller: _serverController,
decoration: InputDecoration(
labelText: 'Server URL',
hintText: 'https://your-pinepods-server.com',
prefixIcon: const Icon(Icons.dns),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a server URL';
}
if (!value.startsWith('http://') && !value.startsWith('https://')) {
return 'URL must start with http:// or https://';
}
return null;
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// Username Field
Focus(
onFocusChange: (hasFocus) {
if (hasFocus) {
// User focused on username field, retry OIDC check if needed
_retryOidcCheck();
}
},
child: TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username',
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your username';
}
return null;
},
textInputAction: TextInputAction.next,
),
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
textInputAction: _showMfaField ? TextInputAction.next : TextInputAction.done,
onFieldSubmitted: (_) {
if (!_showMfaField) {
_connectToPinepods();
}
},
enabled: !_showMfaField,
),
// MFA Field (shown when MFA is required)
if (_showMfaField) ...[
const SizedBox(height: 16),
TextFormField(
controller: _mfaController,
decoration: InputDecoration(
labelText: 'MFA Code',
hintText: 'Enter 6-digit code',
prefixIcon: const Icon(Icons.security),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
suffixIcon: IconButton(
icon: const Icon(Icons.close),
onPressed: _resetMfa,
tooltip: 'Cancel MFA',
),
),
keyboardType: TextInputType.number,
maxLength: 6,
validator: (value) {
if (_showMfaField && (value == null || value.isEmpty)) {
return 'Please enter your MFA code';
}
if (_showMfaField && value!.length != 6) {
return 'MFA code must be 6 digits';
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _connectToPinepods(),
),
],
// Error Message
if (_errorMessage.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
],
const SizedBox(height: 24),
// Connect Button
ElevatedButton(
onPressed: _isLoading ? null : _connectToPinepods,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(
_showMfaField ? 'Verify MFA Code' : 'Connect to PinePods',
style: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// OIDC Providers Section
if (_oidcProviders.isNotEmpty && !_showMfaField) ...[
// Divider
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Or continue with',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 16),
// OIDC Provider Buttons
..._oidcProviders.map((provider) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _handleOidcLogin(provider),
style: ElevatedButton.styleFrom(
backgroundColor: _parseColor(provider.buttonColorHex),
foregroundColor: _parseColor(provider.buttonTextColorHex),
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (provider.iconSvg != null && provider.iconSvg!.isNotEmpty)
Container(
width: 20,
height: 20,
margin: const EdgeInsets.only(right: 8),
child: const Icon(Icons.account_circle, size: 20),
),
Text(
provider.displayText,
style: const TextStyle(fontSize: 16),
),
],
),
),
),
)),
const SizedBox(height: 16),
],
// Loading indicator for OIDC discovery
if (_isLoadingOidc) ...[
const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(height: 16),
],
// Additional Info
Text(
'Don\'t have a PinePods server? Visit pinepods.online to learn more.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
),
),
),
);
}
/// Handle login success from any source (traditional or OIDC)
void _handleLoginSuccess() {
if (mounted) {
widget.onLoginSuccess?.call();
}
}
@override
void dispose() {
_oidcCheckTimer?.cancel();
_serverController.removeListener(_onServerUrlChanged);
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_mfaController.dispose();
// Clear global callback to prevent memory leaks
AuthNotifier.clearGlobalLoginSuccessCallback();
super.dispose();
}
}