- Login reworked to the Brand panel layout with a dark-theme switch; 'Scalar Presence' two-line wordmark - Android project now tracked (was gitignored); pins shared_preferences_android 2.4.27 (2.5.0 crashed with a black screen) - API base URL fixed per build (--dart-define=API_BASE_URL), no in-app server setting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
648 lines
22 KiB
Dart
648 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
/// True for test builds pointed at the demo server (shows a "Demo" badge on the sign-in screen).
|
|
const _apiBaseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: '');
|
|
final bool kDemoBuild = _apiBaseUrl.isNotEmpty && !_apiBaseUrl.contains('api.scalar360.com');
|
|
|
|
/// Light and dark colours for the sign-in screens (Scalar360 brand tokens).
|
|
class AuthPalette {
|
|
final bool dark;
|
|
final Color page, surface, text, title, muted, line, border, accent, link, danger, fieldFill;
|
|
const AuthPalette._({
|
|
required this.dark,
|
|
required this.page,
|
|
required this.surface,
|
|
required this.text,
|
|
required this.title,
|
|
required this.muted,
|
|
required this.line,
|
|
required this.border,
|
|
required this.accent,
|
|
required this.link,
|
|
required this.danger,
|
|
required this.fieldFill,
|
|
});
|
|
|
|
static const light = AuthPalette._(
|
|
dark: false,
|
|
page: Color(0xFFFFFFFF),
|
|
surface: Color(0xFFFFFFFF),
|
|
text: Color(0xFF1B1B1B),
|
|
title: Color(0xFF0B2545),
|
|
muted: Color(0xFF616A75),
|
|
line: Color(0xFF8A8A8A),
|
|
border: Color(0xFFD5DCE5),
|
|
accent: Color(0xFF00965B),
|
|
link: Color(0xFF00965B),
|
|
danger: Color(0xFFC62828),
|
|
fieldFill: Color(0xFFF7F9FB),
|
|
);
|
|
|
|
static const darkPalette = AuthPalette._(
|
|
dark: true,
|
|
page: Color(0xFF0E141B),
|
|
surface: Color(0xFF151D26),
|
|
text: Color(0xFFE8EDF2),
|
|
title: Color(0xFFF1F5F9),
|
|
muted: Color(0xFF9AA6B2),
|
|
line: Color(0xFF5B6875),
|
|
border: Color(0xFF2A3542),
|
|
accent: Color(0xFF00BE73),
|
|
link: Color(0xFF34D399),
|
|
danger: Color(0xFFF87171),
|
|
fieldFill: Color(0xFF1B2530),
|
|
);
|
|
}
|
|
|
|
/// Kept for screens that used the old names.
|
|
class AuthColors {
|
|
static const emerald = Color(0xFF00BE73);
|
|
static const navy = Color(0xFF0B2545);
|
|
static const muted = Color(0xFF616A75);
|
|
}
|
|
|
|
/// The palette for the current sign-in screen.
|
|
class AuthScope extends InheritedWidget {
|
|
final AuthPalette palette;
|
|
const AuthScope({super.key, required this.palette, required super.child});
|
|
|
|
static AuthScope of(BuildContext context) => context.dependOnInheritedWidgetOfExactType<AuthScope>()!;
|
|
|
|
@override
|
|
bool updateShouldNotify(AuthScope old) => old.palette != palette;
|
|
}
|
|
|
|
/// Light / dark choice for the sign-in screens, remembered on the device ("system" until the user picks).
|
|
class AuthThemeMode {
|
|
static const _key = 'scalar360.auth.theme';
|
|
static final notifier = ValueNotifier<ThemeMode>(ThemeMode.system);
|
|
static bool _loaded = false;
|
|
|
|
static Future<void> load() async {
|
|
if (_loaded) return;
|
|
_loaded = true;
|
|
final v = (await SharedPreferences.getInstance()).getString(_key);
|
|
notifier.value = v == 'dark' ? ThemeMode.dark : v == 'light' ? ThemeMode.light : ThemeMode.system;
|
|
}
|
|
|
|
static Future<void> set(ThemeMode mode) async {
|
|
notifier.value = mode;
|
|
await (await SharedPreferences.getInstance()).setString(_key, mode == ThemeMode.dark ? 'dark' : 'light');
|
|
}
|
|
}
|
|
|
|
/// "Keep me signed in": when it was off at sign-in, the saved session is dropped at the next app start.
|
|
class KeepSignedIn {
|
|
static const _key = 'scalar360.auth.keep_signed_in';
|
|
static Future<void> save(bool keep) async => (await SharedPreferences.getInstance()).setBool(_key, keep);
|
|
static Future<bool> wanted() async => (await SharedPreferences.getInstance()).getBool(_key) ?? true;
|
|
}
|
|
|
|
/// Sign-in screen: a brand panel (logo, app name, tagline) above a rounded sheet with the form; light or dark.
|
|
class AuthShell extends StatefulWidget {
|
|
final String appName;
|
|
final String title;
|
|
/// Second line of the brand title under "Scalar360", e.g. "Presence" or "Point of Sale".
|
|
final String product;
|
|
/// One line about the app, shown on the brand panel.
|
|
final String tagline;
|
|
final List<Widget> children;
|
|
|
|
const AuthShell({
|
|
super.key,
|
|
required this.appName,
|
|
required this.product,
|
|
this.title = 'Sign in',
|
|
this.tagline = '',
|
|
required this.children,
|
|
});
|
|
|
|
@override
|
|
State<AuthShell> createState() => _AuthShellState();
|
|
}
|
|
|
|
class _AuthShellState extends State<AuthShell> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
AuthThemeMode.load();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ValueListenableBuilder<ThemeMode>(
|
|
valueListenable: AuthThemeMode.notifier,
|
|
builder: (context, mode, _) {
|
|
final media = MediaQuery.of(context);
|
|
final dark = mode == ThemeMode.dark || (mode == ThemeMode.system && media.platformBrightness == Brightness.dark);
|
|
final palette = dark ? AuthPalette.darkPalette : AuthPalette.light;
|
|
return AuthScope(
|
|
palette: palette,
|
|
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
|
// The brand panel is dark in both themes, so the status bar icons are always light.
|
|
value: SystemUiOverlayStyle.light.copyWith(
|
|
statusBarColor: Colors.transparent,
|
|
systemNavigationBarColor: palette.page,
|
|
systemNavigationBarIconBrightness: dark ? Brightness.light : Brightness.dark,
|
|
),
|
|
child: MediaQuery(
|
|
data: media.copyWith(textScaler: media.textScaler.clamp(minScaleFactor: 1, maxScaleFactor: 1.3)),
|
|
child: Theme(
|
|
data: Theme.of(context).copyWith(
|
|
brightness: dark ? Brightness.dark : Brightness.light,
|
|
textSelectionTheme: TextSelectionThemeData(
|
|
cursorColor: palette.accent,
|
|
selectionColor: palette.accent.withValues(alpha: 0.3),
|
|
selectionHandleColor: palette.accent,
|
|
),
|
|
),
|
|
child: _PanelLayout(shell: widget, palette: palette),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ brand panel + sheet
|
|
|
|
class _PanelLayout extends StatelessWidget {
|
|
final AuthShell shell;
|
|
final AuthPalette palette;
|
|
const _PanelLayout({required this.shell, required this.palette});
|
|
|
|
static const _gradient = LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0xFF0B2545), Color(0xFF0B4A3C)],
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final media = MediaQuery.of(context);
|
|
final keyboard = media.viewInsets.bottom > 0;
|
|
// Side by side on tablets and phones held sideways; stacked on phones held upright.
|
|
final wide = media.size.width >= 720 || (media.size.width > media.size.height && media.size.width >= 560);
|
|
final side = media.size.width < 360 ? 20.0 : 24.0;
|
|
|
|
Widget form({required EdgeInsets padding}) => _FillScroll(
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 440),
|
|
child: Padding(
|
|
padding: padding,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_Title(shell.title),
|
|
const SizedBox(height: 20),
|
|
...shell.children,
|
|
const Spacer(),
|
|
const SizedBox(height: 20),
|
|
const _Footer(padding: EdgeInsets.zero),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (wide) {
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF0B2545),
|
|
body: DecoratedBox(
|
|
decoration: const BoxDecoration(gradient: _gradient),
|
|
child: SafeArea(
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: _BrandPanel(shell: shell, compact: false, side: 32),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 16, 16, 16),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(28),
|
|
child: ColoredBox(
|
|
color: palette.surface,
|
|
child: form(padding: const EdgeInsets.fromLTRB(32, 32, 32, 20)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Upright phone: the panel (behind the status bar too) over a rounded sheet with the form.
|
|
final panelBody = keyboard ? 64.0 : (media.size.height * 0.30).clamp(200.0, 270.0);
|
|
return Scaffold(
|
|
backgroundColor: palette.surface,
|
|
body: Column(children: [
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOut,
|
|
height: media.padding.top + panelBody + 28,
|
|
decoration: const BoxDecoration(gradient: _gradient),
|
|
child: Padding(
|
|
padding: EdgeInsets.only(top: media.padding.top, bottom: 28),
|
|
child: _BrandPanel(shell: shell, compact: keyboard, side: side),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Transform.translate(
|
|
// the sheet's rounded top sits over the panel's lower edge
|
|
offset: const Offset(0, -28),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: palette.surface,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: form(padding: EdgeInsets.fromLTRB(side, 28, side, 12)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BrandPanel extends StatelessWidget {
|
|
final AuthShell shell;
|
|
final bool compact;
|
|
final double side;
|
|
const _BrandPanel({required this.shell, required this.compact, required this.side});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget knot(double h) =>
|
|
Image.asset('assets/brand/scalar-knot.png', height: h, semanticLabel: 'Scalar360', filterQuality: FilterQuality.medium);
|
|
|
|
final content = compact
|
|
? Row(children: [
|
|
knot(34),
|
|
const SizedBox(width: 12),
|
|
Flexible(
|
|
child: Text('Scalar360 ${shell.product}',
|
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: _name(19, FontWeight.w600)),
|
|
),
|
|
])
|
|
: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
knot(60),
|
|
const SizedBox(height: 14),
|
|
Text('Scalar360', style: _name(17, FontWeight.w500, opacity: 0.85)),
|
|
Text(shell.product, style: _name(30, FontWeight.w700, height: 1.15)),
|
|
if (shell.tagline.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(shell.tagline, style: const TextStyle(fontSize: 14, height: 1.45, color: Color(0xCCFFFFFF))),
|
|
],
|
|
],
|
|
);
|
|
|
|
return Stack(children: [
|
|
// soft glow, kept inside the panel
|
|
Positioned.fill(
|
|
child: IgnorePointer(
|
|
child: ClipRect(
|
|
child: Align(
|
|
alignment: Alignment.topRight,
|
|
child: FractionalTranslation(
|
|
translation: const Offset(0.3, -0.3),
|
|
child: Container(
|
|
width: 240,
|
|
height: 240,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(colors: [Color(0x5500BE73), Color(0x0000BE73)]),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(side, 0, side - 12, 0),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
|
if (!compact)
|
|
Row(children: [
|
|
if (kDemoBuild) const _DemoBadge(),
|
|
const Spacer(),
|
|
const _ThemeToggle(onDark: true),
|
|
]),
|
|
Expanded(
|
|
child: compact
|
|
? Row(children: [
|
|
Expanded(child: content),
|
|
const _ThemeToggle(onDark: true),
|
|
])
|
|
: Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
// shrinks instead of overflowing on short screens
|
|
child: FittedBox(fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: content),
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
]);
|
|
}
|
|
|
|
static TextStyle _name(double size, FontWeight weight, {double opacity = 1, double height = 1.25}) => TextStyle(
|
|
fontSize: size,
|
|
fontWeight: weight,
|
|
height: height,
|
|
color: Colors.white.withValues(alpha: opacity),
|
|
letterSpacing: -0.2,
|
|
);
|
|
}
|
|
|
|
class _DemoBadge extends StatelessWidget {
|
|
const _DemoBadge();
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(color: const Color(0xFFF59E0B), borderRadius: BorderRadius.circular(999)),
|
|
child: const Text('DEMO', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF1F2937), letterSpacing: 1)),
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ shared pieces
|
|
|
|
/// Scrollable area whose child fills at least the screen height (so Spacers work and nothing overflows).
|
|
class _FillScroll extends StatelessWidget {
|
|
final Widget child;
|
|
const _FillScroll({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
CustomScrollView(slivers: [SliverFillRemaining(hasScrollBody: false, child: child)]);
|
|
}
|
|
|
|
class _Title extends StatelessWidget {
|
|
final String text;
|
|
const _Title(this.text);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = AuthScope.of(context).palette;
|
|
return Text(text,
|
|
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600, color: p.title, letterSpacing: -0.3));
|
|
}
|
|
}
|
|
|
|
/// Sun / moon button that switches the sign-in screens between light and dark.
|
|
class _ThemeToggle extends StatelessWidget {
|
|
final bool onDark;
|
|
const _ThemeToggle({this.onDark = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = AuthScope.of(context).palette;
|
|
final color = onDark ? Colors.white : p.muted;
|
|
return IconButton(
|
|
tooltip: p.dark ? 'Light theme' : 'Dark theme',
|
|
onPressed: () => AuthThemeMode.set(p.dark ? ThemeMode.light : ThemeMode.dark),
|
|
icon: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 250),
|
|
transitionBuilder: (child, anim) => RotationTransition(turns: anim, child: FadeTransition(opacity: anim, child: child)),
|
|
child: Icon(p.dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
|
|
key: ValueKey(p.dark), color: color, size: 22),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Footer extends StatelessWidget {
|
|
final EdgeInsets padding;
|
|
const _Footer({this.padding = const EdgeInsets.fromLTRB(24, 8, 24, 16)});
|
|
|
|
Future<void> _open(String url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = AuthScope.of(context).palette;
|
|
final style = TextStyle(fontSize: 12, color: p.muted);
|
|
return Padding(
|
|
padding: padding,
|
|
child: Wrap(alignment: WrapAlignment.center, spacing: 18, children: [
|
|
GestureDetector(onTap: () => _open('https://scalar360.com/terms'), child: Text('Terms of use', style: style)),
|
|
GestureDetector(onTap: () => _open('https://scalar360.com/privacy'), child: Text('Privacy', style: style)),
|
|
Text('© ${DateTime.now().year} Scalar360', style: style),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Second step: a back arrow with the identifier entered on the first step.
|
|
class AuthIdentity extends StatelessWidget {
|
|
final String value;
|
|
final VoidCallback onBack;
|
|
const AuthIdentity({super.key, required this.value, required this.onBack});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = AuthScope.of(context).palette;
|
|
final row = Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Icon(Icons.arrow_back, size: 18, color: p.text),
|
|
const SizedBox(width: 8),
|
|
Flexible(child: Text(value, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14.5, color: p.text))),
|
|
]);
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: InkWell(
|
|
onTap: onBack,
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
|
decoration: BoxDecoration(border: Border.all(color: p.border), borderRadius: BorderRadius.circular(999)),
|
|
child: row,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Rounded, outlined text field with a leading icon.
|
|
class AuthField extends StatefulWidget {
|
|
final TextEditingController controller;
|
|
final String hint;
|
|
final IconData? icon;
|
|
final bool secret;
|
|
final bool autofocus;
|
|
final TextInputType? keyboardType;
|
|
final TextInputAction? action;
|
|
final ValueChanged<String>? onSubmitted;
|
|
final bool error;
|
|
|
|
const AuthField({
|
|
super.key,
|
|
required this.controller,
|
|
required this.hint,
|
|
this.icon,
|
|
this.secret = false,
|
|
this.autofocus = false,
|
|
this.keyboardType,
|
|
this.action,
|
|
this.onSubmitted,
|
|
this.error = false,
|
|
});
|
|
|
|
@override
|
|
State<AuthField> createState() => _AuthFieldState();
|
|
}
|
|
|
|
class _AuthFieldState extends State<AuthField> {
|
|
bool _hidden = true;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = AuthScope.of(context).palette;
|
|
final base = widget.error ? p.danger : p.border;
|
|
final focus = widget.error ? p.danger : p.accent;
|
|
InputBorder border(Color c, double w) =>
|
|
OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: c, width: w));
|
|
return TextField(
|
|
controller: widget.controller,
|
|
obscureText: widget.secret && _hidden,
|
|
autofocus: widget.autofocus,
|
|
autocorrect: false,
|
|
enableSuggestions: !widget.secret,
|
|
keyboardType: widget.keyboardType,
|
|
textInputAction: widget.action,
|
|
onSubmitted: widget.onSubmitted,
|
|
style: TextStyle(fontSize: 16, color: p.text),
|
|
decoration: InputDecoration(
|
|
hintText: widget.hint,
|
|
hintStyle: TextStyle(fontSize: 16, color: p.muted),
|
|
filled: true,
|
|
fillColor: p.fieldFill,
|
|
prefixIcon: widget.icon == null ? null : Icon(widget.icon, color: p.muted, size: 20),
|
|
suffixIcon: widget.secret
|
|
? IconButton(
|
|
tooltip: _hidden ? 'Show' : 'Hide',
|
|
icon: Icon(_hidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, color: p.muted, size: 20),
|
|
onPressed: () => setState(() => _hidden = !_hidden),
|
|
)
|
|
: null,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
enabledBorder: border(base, 1),
|
|
focusedBorder: border(focus, 2),
|
|
border: border(base, 1),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Red message above the field.
|
|
class AuthError extends StatelessWidget {
|
|
final String message;
|
|
const AuthError(this.message, {super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(message, style: TextStyle(color: AuthScope.of(context).palette.danger, fontSize: 14)),
|
|
);
|
|
}
|
|
|
|
/// Small text link.
|
|
class AuthLink extends StatelessWidget {
|
|
final String label;
|
|
final VoidCallback? onPressed;
|
|
const AuthLink(this.label, {super.key, this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: InkWell(
|
|
onTap: onPressed,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: AuthScope.of(context).palette.link)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "Keep me signed in" checkbox.
|
|
class AuthKeepSignedIn extends StatelessWidget {
|
|
final bool value;
|
|
final ValueChanged<bool> onChanged;
|
|
const AuthKeepSignedIn({super.key, required this.value, required this.onChanged});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = AuthScope.of(context).palette;
|
|
return InkWell(
|
|
onTap: () => onChanged(!value),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(children: [
|
|
SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: Checkbox(
|
|
value: value,
|
|
onChanged: (v) => onChanged(v ?? true),
|
|
activeColor: p.accent,
|
|
side: BorderSide(color: p.line, width: 1.5),
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text('Keep me signed in', style: TextStyle(fontSize: 14, color: p.text)),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Full-width button for the step, with a spinner while busy.
|
|
class AuthButton extends StatelessWidget {
|
|
final String label;
|
|
final bool busy;
|
|
final VoidCallback onPressed;
|
|
const AuthButton({super.key, required this.label, required this.busy, required this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = AuthScope.of(context).palette;
|
|
return FilledButton(
|
|
onPressed: busy ? null : onPressed,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: p.accent,
|
|
disabledBackgroundColor: p.accent.withValues(alpha: 0.6),
|
|
foregroundColor: Colors.white,
|
|
minimumSize: const Size.fromHeight(52),
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
// same family as the rest of the screen (Poppins), which a button style does not inherit by itself
|
|
textStyle: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, fontFamily: DefaultTextStyle.of(context).style.fontFamily),
|
|
elevation: 0,
|
|
),
|
|
child: busy
|
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.2, color: Colors.white))
|
|
: Text(label),
|
|
);
|
|
}
|
|
}
|