Files
athul rdandClaude Opus 5.5 2dbd1a2ea9 Scalar Presence: brand-panel login (light/dark), Poppins, splash and launcher icons, fixed API base via dart-define, keep-signed-in, Android project
- 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>
2026-10-19 15:51:09 +05:30

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),
);
}
}