229 lines
9.7 KiB
Dart
229 lines
9.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:relationship_saver/core/config/app_config.dart';
|
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
|
import 'package:relationship_saver/features/auth/session_controller.dart';
|
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
|
|
|
class SignInView extends ConsumerStatefulWidget {
|
|
const SignInView({super.key});
|
|
|
|
@override
|
|
ConsumerState<SignInView> createState() => _SignInViewState();
|
|
}
|
|
|
|
class _SignInViewState extends ConsumerState<SignInView> {
|
|
SignInMethod _method = SignInMethod.password;
|
|
final TextEditingController _emailController = TextEditingController(
|
|
text: 'demo@example.com',
|
|
);
|
|
final TextEditingController _passwordController = TextEditingController(
|
|
text: 'password123',
|
|
);
|
|
final TextEditingController _idTokenController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_emailController.dispose();
|
|
_passwordController.dispose();
|
|
_idTokenController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final AsyncValue<AuthSession?> session = ref.watch(
|
|
sessionControllerProvider,
|
|
);
|
|
final bool busy = session.isLoading;
|
|
final Object? error = session.asError?.error;
|
|
|
|
return LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints viewport) {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 620),
|
|
child: FrostedCard(
|
|
child: LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
final bool compact = constraints.maxWidth < 480;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
'Sign in to continue',
|
|
style: Theme.of(context).textTheme.headlineMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Your local data remains available offline. Backend unlocks sync and signals.',
|
|
style: Theme.of(context).textTheme.bodyLarge
|
|
?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 18),
|
|
if (compact)
|
|
DropdownButtonFormField<SignInMethod>(
|
|
initialValue: _method,
|
|
items: SignInMethod.values
|
|
.map(
|
|
(SignInMethod method) =>
|
|
DropdownMenuItem<SignInMethod>(
|
|
value: method,
|
|
child: Text(_labelForMethod(method)),
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
onChanged: busy
|
|
? null
|
|
: (SignInMethod? value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_method = value;
|
|
});
|
|
},
|
|
decoration: const InputDecoration(
|
|
labelText: 'Method',
|
|
),
|
|
)
|
|
else
|
|
SegmentedButton<SignInMethod>(
|
|
segments: const <ButtonSegment<SignInMethod>>[
|
|
ButtonSegment<SignInMethod>(
|
|
value: SignInMethod.password,
|
|
label: Text('Password'),
|
|
),
|
|
ButtonSegment<SignInMethod>(
|
|
value: SignInMethod.emailMagicLink,
|
|
label: Text('Magic Link'),
|
|
),
|
|
ButtonSegment<SignInMethod>(
|
|
value: SignInMethod.oidc,
|
|
label: Text('OIDC'),
|
|
),
|
|
],
|
|
selected: <SignInMethod>{_method},
|
|
onSelectionChanged: busy
|
|
? null
|
|
: (Set<SignInMethod> value) {
|
|
setState(() {
|
|
_method = value.first;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 14),
|
|
if (_method == SignInMethod.password ||
|
|
_method == SignInMethod.emailMagicLink)
|
|
TextField(
|
|
controller: _emailController,
|
|
keyboardType: TextInputType.emailAddress,
|
|
enabled: !busy,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Email',
|
|
),
|
|
),
|
|
if (_method == SignInMethod.password)
|
|
TextField(
|
|
controller: _passwordController,
|
|
obscureText: true,
|
|
enabled: !busy,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Password',
|
|
),
|
|
),
|
|
if (_method == SignInMethod.oidc)
|
|
TextField(
|
|
controller: _idTokenController,
|
|
enabled: !busy,
|
|
decoration: const InputDecoration(
|
|
labelText: 'ID Token',
|
|
),
|
|
),
|
|
if (error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 10),
|
|
child: Text(
|
|
'Sign-in failed: $error',
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: const Color(0xFFC83030)),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: <Widget>[
|
|
FilledButton.icon(
|
|
onPressed: busy ? null : _submit,
|
|
icon: busy
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Icon(Icons.login_rounded),
|
|
label: const Text('Sign In'),
|
|
),
|
|
Text(
|
|
AppConfig.useFakeBackend
|
|
? 'Fake mode enabled'
|
|
: 'REST mode (${AppConfig.backendBaseUrl})',
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
final String? email = _emailController.text.trim().isEmpty
|
|
? null
|
|
: _emailController.text.trim();
|
|
final String? password = _passwordController.text.trim().isEmpty
|
|
? null
|
|
: _passwordController.text.trim();
|
|
final String? idToken = _idTokenController.text.trim().isEmpty
|
|
? null
|
|
: _idTokenController.text.trim();
|
|
|
|
await ref
|
|
.read(sessionControllerProvider.notifier)
|
|
.signIn(
|
|
method: _method,
|
|
email: email,
|
|
password: password,
|
|
idToken: idToken,
|
|
);
|
|
}
|
|
|
|
String _labelForMethod(SignInMethod method) {
|
|
return switch (method) {
|
|
SignInMethod.password => 'Password',
|
|
SignInMethod.emailMagicLink => 'Magic Link',
|
|
SignInMethod.oidc => 'OIDC',
|
|
};
|
|
}
|
|
}
|