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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
19a79d6126
commit
2dbd1a2ea9
@@ -111,7 +111,7 @@ class ApiService {
|
||||
ApiService(this.settings);
|
||||
|
||||
Uri _uri(String p, [Map<String, String>? q]) =>
|
||||
Uri.parse('${settings.baseUrl}$p').replace(queryParameters: q);
|
||||
Uri.parse('${Settings.baseUrl}$p').replace(queryParameters: q);
|
||||
|
||||
Map<String, String> get _h => {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'api_service.dart';
|
||||
import 'settings.dart';
|
||||
import 'theme.dart';
|
||||
import 'screens/auth_shell.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
@@ -9,6 +10,7 @@ Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final settings = Settings();
|
||||
await settings.load();
|
||||
if (settings.isLoggedIn && !await KeepSignedIn.wanted()) await settings.clearSession();
|
||||
runApp(ScalarEssApp(settings: settings));
|
||||
}
|
||||
|
||||
@@ -20,7 +22,7 @@ class ScalarEssApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final api = ApiService(settings);
|
||||
return MaterialApp(
|
||||
title: 'Scalar360 Attendance',
|
||||
title: 'Scalar Presence',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildTheme(),
|
||||
home: settings.isLoggedIn
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../api_service.dart';
|
||||
import '../settings.dart';
|
||||
import '../theme.dart';
|
||||
import 'auth_shell.dart';
|
||||
import 'home_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
final ApiService api;
|
||||
@@ -19,15 +18,32 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
final _pin = TextEditingController();
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
/// Zoho-style: the email first, then the PIN on its own step.
|
||||
bool _askPin = false;
|
||||
bool _keep = true;
|
||||
|
||||
static final _emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||
|
||||
void _next() {
|
||||
final email = _email.text.trim();
|
||||
if (!_emailPattern.hasMatch(email)) {
|
||||
setState(() => _error = 'Enter a valid work email address');
|
||||
return;
|
||||
}
|
||||
setState(() { _askPin = true; _error = null; });
|
||||
}
|
||||
|
||||
void _change() => setState(() { _askPin = false; _pin.clear(); _error = null; });
|
||||
|
||||
Future<void> _login() async {
|
||||
if (_email.text.trim().isEmpty || _pin.text.isEmpty) {
|
||||
setState(() => _error = 'Enter your work email and PIN');
|
||||
if (_pin.text.isEmpty) {
|
||||
setState(() => _error = 'Enter your PIN');
|
||||
return;
|
||||
}
|
||||
setState(() { _busy = true; _error = null; });
|
||||
try {
|
||||
await widget.api.login(_email.text, _pin.text);
|
||||
await widget.api.login(_email.text.trim(), _pin.text);
|
||||
await KeepSignedIn.save(_keep);
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacement(context,
|
||||
MaterialPageRoute(builder: (_) => HomeScreen(api: widget.api, settings: widget.settings)));
|
||||
@@ -38,82 +54,73 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _forgotEmail() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Use the work email HR added you with. Not sure which one? Ask your HR or manager.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
}
|
||||
|
||||
void _forgotPin() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('Your PIN is set by HR. Ask your HR or manager to reset it.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(color: AppColors.emerald, borderRadius: BorderRadius.circular(12)),
|
||||
child: const Icon(Icons.badge_outlined, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('Scalar360', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
||||
]),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined, color: AppColors.muted),
|
||||
onPressed: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => SettingsScreen(settings: widget.settings))),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 28),
|
||||
const Text('Employee sign in',
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('Use your work email and the PIN from HR', style: TextStyle(color: AppColors.muted)),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(labelText: 'Work email', prefixIcon: Icon(Icons.mail_outline)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _pin,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _login(),
|
||||
decoration: const InputDecoration(labelText: 'PIN', prefixIcon: Icon(Icons.pin_outlined)),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: AppColors.danger.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.error_outline, color: AppColors.danger, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_error!, style: const TextStyle(color: AppColors.danger))),
|
||||
]),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _login,
|
||||
child: _busy
|
||||
? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Text('Sign in'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Server: ${widget.settings.baseUrl}',
|
||||
textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_askPin) {
|
||||
return AuthShell(
|
||||
appName: 'Scalar Presence',
|
||||
product: 'Presence',
|
||||
tagline: 'Check in, apply for leave and see your payslips.',
|
||||
title: 'Enter PIN',
|
||||
children: [
|
||||
AuthIdentity(value: _email.text.trim(), onBack: _busy ? () {} : _change),
|
||||
const SizedBox(height: 16),
|
||||
if (_error != null) AuthError(_error!),
|
||||
AuthField(
|
||||
key: const ValueKey('pin'),
|
||||
controller: _pin,
|
||||
hint: 'PIN',
|
||||
icon: Icons.lock_outline,
|
||||
secret: true,
|
||||
autofocus: true,
|
||||
error: _error != null,
|
||||
keyboardType: TextInputType.number,
|
||||
action: TextInputAction.done,
|
||||
onSubmitted: (_) => _login(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AuthLink('Forgot PIN?', onPressed: _forgotPin),
|
||||
AuthKeepSignedIn(value: _keep, onChanged: (v) => setState(() => _keep = v)),
|
||||
const SizedBox(height: 20),
|
||||
AuthButton(label: 'Sign in', busy: _busy, onPressed: _login),
|
||||
],
|
||||
);
|
||||
}
|
||||
return AuthShell(
|
||||
appName: 'Scalar Presence',
|
||||
product: 'Presence',
|
||||
tagline: 'Check in, apply for leave and see your payslips.',
|
||||
children: [
|
||||
if (_error != null) AuthError(_error!),
|
||||
AuthField(
|
||||
key: const ValueKey('email'),
|
||||
controller: _email,
|
||||
hint: 'Work email',
|
||||
icon: Icons.mail_outline,
|
||||
error: _error != null,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
action: TextInputAction.next,
|
||||
onSubmitted: (_) => _next(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AuthLink('Forgot email?', onPressed: _forgotEmail),
|
||||
const SizedBox(height: 28),
|
||||
AuthButton(label: 'Next', busy: false, onPressed: _next),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,6 @@ class SettingsScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
late final TextEditingController _url = TextEditingController(text: widget.settings.baseUrl);
|
||||
|
||||
void _save() {
|
||||
widget.settings.baseUrl = _url.text.trim();
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Saved')));
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = widget.settings.name;
|
||||
@@ -36,22 +28,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
subtitle: Text(widget.settings.isManager ? 'Manager' : 'Employee'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text('Server URL', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _url,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(prefixIcon: Icon(Icons.link), hintText: Settings.defaultBaseUrl),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text('The Scalar360 API base URL. Default is api.scalar360.in.',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
if (widget.onLogout != null) ...[
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
onPressed: widget.onLogout,
|
||||
icon: const Icon(Icons.logout, color: AppColors.danger),
|
||||
|
||||
+3
-5
@@ -2,19 +2,17 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Persisted app settings + employee session.
|
||||
class Settings {
|
||||
static const _kBaseUrl = 'base_url';
|
||||
static const _kToken = 'emp_token';
|
||||
static const _kName = 'emp_name';
|
||||
static const _kRole = 'emp_role';
|
||||
|
||||
static const defaultBaseUrl = 'https://api.scalar360.in';
|
||||
/// Scalar360 API. Fixed per build (not shown to or changed by the user); a test build points at demo with
|
||||
/// `--dart-define=API_BASE_URL=https://demo.scalar360.in`.
|
||||
static const baseUrl = String.fromEnvironment('API_BASE_URL', defaultValue: 'https://api.scalar360.com');
|
||||
|
||||
late SharedPreferences _prefs;
|
||||
Future<void> load() async => _prefs = await SharedPreferences.getInstance();
|
||||
|
||||
String get baseUrl => (_prefs.getString(_kBaseUrl) ?? defaultBaseUrl).trim();
|
||||
set baseUrl(String v) => _prefs.setString(_kBaseUrl, v.trim());
|
||||
|
||||
String? get token => _prefs.getString(_kToken);
|
||||
String? get name => _prefs.getString(_kName);
|
||||
String get role => _prefs.getString(_kRole) ?? 'EMPLOYEE';
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ ThemeData buildTheme() {
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: AppColors.surface,
|
||||
fontFamily: 'Roboto',
|
||||
fontFamily: 'Poppins',
|
||||
);
|
||||
return base.copyWith(
|
||||
appBarTheme: const AppBarTheme(
|
||||
|
||||
Reference in New Issue
Block a user