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:
athul rd
2026-10-19 15:51:09 +05:30
co-authored by Claude Opus 5.5
parent 19a79d6126
commit 2dbd1a2ea9
66 changed files with 1463 additions and 140 deletions
-1
View File
@@ -7,7 +7,6 @@ build/
.idea/ .idea/
.DS_Store .DS_Store
ios/ ios/
android/
web/ web/
macos/ macos/
linux/ linux/
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "84fc5cbb223bc12f83d65b647ff8a56caf779ffd"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd
base_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd
- platform: android
create_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd
base_revision: 84fc5cbb223bc12f83d65b647ff8a56caf779ffd
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+45
View File
@@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.fistinnovations.scalar_attendance"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.fistinnovations.scalar_attendance"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+54
View File
@@ -0,0 +1,54 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="Scalar Presence"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.fistinnovations.scalar_attendance
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#FFFFFF</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#FFFFFF</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#FFFFFF</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#FFFFFF</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -111,7 +111,7 @@ class ApiService {
ApiService(this.settings); ApiService(this.settings);
Uri _uri(String p, [Map<String, String>? q]) => 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 => { Map<String, String> get _h => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+3 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'api_service.dart'; import 'api_service.dart';
import 'settings.dart'; import 'settings.dart';
import 'theme.dart'; import 'theme.dart';
import 'screens/auth_shell.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
@@ -9,6 +10,7 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final settings = Settings(); final settings = Settings();
await settings.load(); await settings.load();
if (settings.isLoggedIn && !await KeepSignedIn.wanted()) await settings.clearSession();
runApp(ScalarEssApp(settings: settings)); runApp(ScalarEssApp(settings: settings));
} }
@@ -20,7 +22,7 @@ class ScalarEssApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final api = ApiService(settings); final api = ApiService(settings);
return MaterialApp( return MaterialApp(
title: 'Scalar360 Attendance', title: 'Scalar Presence',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildTheme(), theme: buildTheme(),
home: settings.isLoggedIn home: settings.isLoggedIn
+647
View File
@@ -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),
);
}
}
+82 -75
View File
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../api_service.dart'; import '../api_service.dart';
import '../settings.dart'; import '../settings.dart';
import '../theme.dart'; import 'auth_shell.dart';
import 'home_screen.dart'; import 'home_screen.dart';
import 'settings_screen.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
final ApiService api; final ApiService api;
@@ -19,15 +18,32 @@ class _LoginScreenState extends State<LoginScreen> {
final _pin = TextEditingController(); final _pin = TextEditingController();
bool _busy = false; bool _busy = false;
String? _error; 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 { Future<void> _login() async {
if (_email.text.trim().isEmpty || _pin.text.isEmpty) { if (_pin.text.isEmpty) {
setState(() => _error = 'Enter your work email and PIN'); setState(() => _error = 'Enter your PIN');
return; return;
} }
setState(() { _busy = true; _error = null; }); setState(() { _busy = true; _error = null; });
try { 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; if (!mounted) return;
Navigator.pushReplacement(context, Navigator.pushReplacement(context,
MaterialPageRoute(builder: (_) => HomeScreen(api: widget.api, settings: widget.settings))); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( if (_askPin) {
body: SafeArea( return AuthShell(
child: Center( appName: 'Scalar Presence',
child: SingleChildScrollView( product: 'Presence',
padding: const EdgeInsets.all(24), tagline: 'Check in, apply for leave and see your payslips.',
child: ConstrainedBox( title: 'Enter PIN',
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ AuthIdentity(value: _email.text.trim(), onBack: _busy ? () {} : _change),
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), const SizedBox(height: 16),
Text('Server: ${widget.settings.baseUrl}', if (_error != null) AuthError(_error!),
textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted, fontSize: 12)), 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),
],
); );
} }
} }
+1 -23
View File
@@ -12,14 +12,6 @@ class SettingsScreen extends StatefulWidget {
} }
class _SettingsScreenState extends State<SettingsScreen> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final name = widget.settings.name; final name = widget.settings.name;
@@ -36,22 +28,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: Text(widget.settings.isManager ? 'Manager' : 'Employee'), 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) ...[ if (widget.onLogout != null) ...[
const SizedBox(height: 32), const SizedBox(height: 20),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: widget.onLogout, onPressed: widget.onLogout,
icon: const Icon(Icons.logout, color: AppColors.danger), icon: const Icon(Icons.logout, color: AppColors.danger),
+3 -5
View File
@@ -2,19 +2,17 @@ import 'package:shared_preferences/shared_preferences.dart';
/// Persisted app settings + employee session. /// Persisted app settings + employee session.
class Settings { class Settings {
static const _kBaseUrl = 'base_url';
static const _kToken = 'emp_token'; static const _kToken = 'emp_token';
static const _kName = 'emp_name'; static const _kName = 'emp_name';
static const _kRole = 'emp_role'; 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; late SharedPreferences _prefs;
Future<void> load() async => _prefs = await SharedPreferences.getInstance(); 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 token => _prefs.getString(_kToken);
String? get name => _prefs.getString(_kName); String? get name => _prefs.getString(_kName);
String get role => _prefs.getString(_kRole) ?? 'EMPLOYEE'; String get role => _prefs.getString(_kRole) ?? 'EMPLOYEE';
+1 -1
View File
@@ -38,7 +38,7 @@ ThemeData buildTheme() {
brightness: Brightness.light, brightness: Brightness.light,
), ),
scaffoldBackgroundColor: AppColors.surface, scaffoldBackgroundColor: AppColors.surface,
fontFamily: 'Roboto', fontFamily: 'Poppins',
); );
return base.copyWith( return base.copyWith(
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
+223 -31
View File
@@ -1,6 +1,30 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
archive:
dependency: transitive
description:
name: archive
sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -17,14 +41,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock: clock:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.3"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -37,10 +77,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: cross_file name: cross_file
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.5+4" version: "0.3.5+5"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +89,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -77,18 +125,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_selector_linux name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.4" version: "0.9.4+1"
file_selector_macos: file_selector_macos:
dependency: transitive dependency: transitive
description: description:
name: file_selector_macos name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.5" version: "0.9.5+1"
file_selector_platform_interface: file_selector_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -101,10 +149,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_selector_windows name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.3+5" version: "0.9.3+6"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -118,6 +166,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -126,6 +182,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
flutter_native_splash:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
url: "https://pub.dev"
source: hosted
version: "2.4.8"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -159,18 +223,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_apple name: geolocator_apple
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" sha256: "3b3189beba8b59b2a0eee970b466e991e4ac1564a9034f82a825a3c45c4be1fa"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.14" version: "2.3.15"
geolocator_platform_interface: geolocator_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: geolocator_platform_interface name: geolocator_platform_interface
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 sha256: b640b6b687b14c66f51fe1130b1c2e8babbd408e0158052b92006b6675ca77fc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.8" version: "4.4.0"
geolocator_web: geolocator_web:
dependency: transitive dependency: transitive
description: description:
@@ -187,6 +251,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.5" version: "0.2.5"
html:
dependency: transitive
description:
name: html
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
url: "https://pub.dev"
source: hosted
version: "0.15.7"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -203,22 +275,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.3" version: "1.2.4"
image_picker_android: image_picker_android:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+19" version: "0.8.13+25"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@@ -231,10 +311,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_ios name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+6" version: "0.8.13+10"
image_picker_linux: image_picker_linux:
dependency: transitive dependency: transitive
description: description:
@@ -255,10 +335,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_platform_interface name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.1" version: "2.11.2"
image_picker_windows: image_picker_windows:
dependency: transitive dependency: transitive
description: description:
@@ -275,6 +355,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.19.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -303,10 +391,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: mime name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -339,14 +427,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
name: platform name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.6" version: "3.2.0"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -355,16 +451,24 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.5" version: "2.5.6"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: "direct main"
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
@@ -375,10 +479,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.6" version: "2.5.7"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@@ -448,6 +552,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.dev"
source: hosted
version: "2.3.1"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
url: "https://pub.dev"
source: hosted
version: "6.3.3"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
url: "https://pub.dev"
source: hosted
version: "6.3.33"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
url: "https://pub.dev"
source: hosted
version: "6.4.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
url: "https://pub.dev"
source: hosted
version: "3.2.6"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
@@ -480,6 +656,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks: sdks:
dart: ">=3.12.0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0" flutter: ">=3.44.0"
+35
View File
@@ -10,14 +10,49 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
http: ^1.2.0 http: ^1.2.0
url_launcher: ^6.3.0
shared_preferences: ^2.2.0 shared_preferences: ^2.2.0
# 2.5.x crashes at start on release builds with Flutter 3.44 (NoSuchMethodError via JNI); POS uses 2.4.27 fine
shared_preferences_android: 2.4.27
intl: ^0.19.0 intl: ^0.19.0
geolocator: ^13.0.1 geolocator: ^13.0.1
image_picker: ^1.1.2 image_picker: ^1.1.2
cupertino_icons: ^1.0.6 cupertino_icons: ^1.0.6
dev_dependencies: dev_dependencies:
flutter_launcher_icons: ^0.14.0
flutter_native_splash: ^2.4.0
flutter_lints: ^4.0.0 flutter_lints: ^4.0.0
flutter_launcher_icons:
android: true
ios: false
image_path: "assets/brand/scalar-mark.png"
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/brand/scalar-mark-maskable.png"
min_sdk_android: 21
flutter_native_splash:
color: "#FFFFFF"
image: assets/brand/scalar-mark.png
android_12:
color: "#FFFFFF"
image: assets/brand/scalar-mark-android12.png
icon_background_color: "#FFFFFF"
ios: false
web: false
flutter: flutter:
uses-material-design: true uses-material-design: true
assets:
- assets/brand/
fonts:
- family: Poppins
fonts:
- asset: assets/fonts/poppins/Poppins-Regular.ttf
- asset: assets/fonts/poppins/Poppins-Medium.ttf
weight: 500
- asset: assets/fonts/poppins/Poppins-SemiBold.ttf
weight: 600
- asset: assets/fonts/poppins/Poppins-Bold.ttf
weight: 700
+30
View File
@@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scalar_attendance/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}