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()!; @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.system); static bool _loaded = false; static Future 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 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 save(bool keep) async => (await SharedPreferences.getInstance()).setBool(_key, keep); static Future 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 children; const AuthShell({ super.key, required this.appName, required this.product, this.title = 'Sign in', this.tagline = '', required this.children, }); @override State createState() => _AuthShellState(); } class _AuthShellState extends State { @override void initState() { super.initState(); AuthThemeMode.load(); } @override Widget build(BuildContext context) { return ValueListenableBuilder( 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( // 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 _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? 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 createState() => _AuthFieldState(); } class _AuthFieldState extends State { 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 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), ); } }