Files
m8chat-app2/lib/features/auth/presentation/login_screen.dart
help4bis d2a2a1ab07 feat(ui): mobile UI v2 — contrast, avatars, room-list density, compose FAB, gradient login (v1.8.0+12)
From a 7-agent design workflow (3 lenses judged + synthesised). Shipped the
high-impact/low-risk tier:
- message bubbles: self #1265ED (WCAG AA pass), incoming #1C2452 + hairline
- deterministic per-user avatar colours (8-hue brand palette)
- room tile: brighter unread preview, compact density, aligned timestamp
- FAB -> New message (thumb zone), explore -> AppBar
- login + welcome brand gradient, card outlines
- input maxLines cap, bubble max-width viewport fraction, AppBar hairline

All verified on production build in headless Chromium at mobile size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:49:53 +10:00

248 lines
6.9 KiB
Dart

// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Login screen. Username + password only. No registration link.
// Respects system theme preference.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../../core/config/app_config.dart';
import 'login_controller.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _passwordVisible = false;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final failure = await ref
.read(loginControllerProvider.notifier)
.login(
username: _usernameController.text.trim(),
password: _passwordController.text,
);
if (failure != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
@override
Widget build(BuildContext context) {
final isLoading = ref.watch(loginControllerProvider);
final theme = Theme.of(context);
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0A0F2E), Color(0xFF131A42)],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_LogoSection(theme: theme),
const SizedBox(height: 48),
_UsernameField(controller: _usernameController),
const SizedBox(height: 16),
_PasswordField(
controller: _passwordController,
isVisible: _passwordVisible,
onToggleVisibility: () {
setState(() => _passwordVisible = !_passwordVisible);
},
onSubmit: _submit,
),
const SizedBox(height: 32),
_SignInButton(isLoading: isLoading, onPressed: _submit),
const SizedBox(height: 16),
_ServerLabel(theme: theme),
],
),
),
),
),
),
),
),
);
}
}
class _LogoSection extends StatelessWidget {
const _LogoSection({required this.theme});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Column(
children: [
SvgPicture.asset(
'assets/images/m8logo.svg',
width: 96,
height: 96,
),
const SizedBox(height: 20),
Text(
'M8Chat',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
'Sign in to continue',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
);
}
}
class _UsernameField extends StatelessWidget {
const _UsernameField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Username',
hintText: 'Enter your username',
prefixIcon: Icon(Icons.person_outline),
),
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your username.';
}
return null;
},
);
}
}
class _PasswordField extends StatelessWidget {
const _PasswordField({
required this.controller,
required this.isVisible,
required this.onToggleVisibility,
required this.onSubmit,
});
final TextEditingController controller;
final bool isVisible;
final VoidCallback onToggleVisibility;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(isVisible ? Icons.visibility_off : Icons.visibility),
onPressed: onToggleVisibility,
tooltip: isVisible ? 'Hide password' : 'Show password',
),
),
obscureText: !isVisible,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => onSubmit(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password.';
}
return null;
},
);
}
}
class _SignInButton extends StatelessWidget {
const _SignInButton({required this.isLoading, required this.onPressed});
final bool isLoading;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: isLoading ? null : onPressed,
child: isLoading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text('Sign In'),
);
}
}
class _ServerLabel extends StatelessWidget {
const _ServerLabel({required this.theme});
final ThemeData theme;
@override
Widget build(BuildContext context) {
return Text(
AppConfig.matrixServerName,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(102),
),
);
}
}