feat: login page

This commit is contained in:
efrilm 2025-08-12 17:13:02 +07:00
parent afc8476701
commit 318d22d7c9
18 changed files with 606 additions and 22 deletions

View File

@ -7,21 +7,21 @@ class AppStyle {
static TextStyle md = TextStyle(color: AppColor.textPrimary, fontSize: 14); static TextStyle md = TextStyle(color: AppColor.textPrimary, fontSize: 14);
static TextStyle lg = TextStyle(color: AppColor.textPrimary, fontSize: 18); static TextStyle lg = TextStyle(color: AppColor.textPrimary, fontSize: 16);
static TextStyle xl = TextStyle(color: AppColor.textPrimary, fontSize: 20); static TextStyle xl = TextStyle(color: AppColor.textPrimary, fontSize: 18);
static TextStyle xxl = TextStyle(color: AppColor.textPrimary, fontSize: 22); static TextStyle xxl = TextStyle(color: AppColor.textPrimary, fontSize: 20);
static TextStyle h6 = TextStyle(color: AppColor.textPrimary, fontSize: 24); static TextStyle h6 = TextStyle(color: AppColor.textPrimary, fontSize: 22);
static TextStyle h5 = TextStyle(color: AppColor.textPrimary, fontSize: 26); static TextStyle h5 = TextStyle(color: AppColor.textPrimary, fontSize: 24);
static TextStyle h4 = TextStyle(color: AppColor.textPrimary, fontSize: 28); static TextStyle h4 = TextStyle(color: AppColor.textPrimary, fontSize: 26);
static TextStyle h3 = TextStyle(color: AppColor.textPrimary, fontSize: 30); static TextStyle h3 = TextStyle(color: AppColor.textPrimary, fontSize: 28);
static TextStyle h2 = TextStyle(color: AppColor.textPrimary, fontSize: 32); static TextStyle h2 = TextStyle(color: AppColor.textPrimary, fontSize: 30);
static TextStyle h1 = TextStyle(color: AppColor.textPrimary, fontSize: 34); static TextStyle h1 = TextStyle(color: AppColor.textPrimary, fontSize: 32);
} }

View File

@ -2,6 +2,7 @@ part of 'theme.dart';
class AppValue { class AppValue {
static const double padding = 16.0; static const double padding = 16.0;
static const double margin = 16.0;
static const double radius = 8.0; static const double radius = 8.0;
static const double elevation = 4.0; static const double elevation = 4.0;
} }

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../presentation/components/assets/fonts.gen.dart';
part 'app_color.dart'; part 'app_color.dart';
part 'app_style.dart'; part 'app_style.dart';
part 'app_value.dart'; part 'app_value.dart';
@ -7,5 +9,38 @@ part 'app_value.dart';
class ThemeApp { class ThemeApp {
static ThemeData get theme => ThemeData( static ThemeData get theme => ThemeData(
useMaterial3: true, useMaterial3: true,
scaffoldBackgroundColor: AppColor.background,
fontFamily: FontFamily.quicksand,
inputDecorationTheme: InputDecorationTheme(
hintStyle: AppStyle.md.copyWith(color: AppColor.textSecondary),
contentPadding: const EdgeInsets.symmetric(horizontal: AppValue.padding),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppValue.radius),
borderSide: const BorderSide(color: AppColor.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppValue.radius),
borderSide: const BorderSide(color: AppColor.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppValue.radius),
borderSide: const BorderSide(color: AppColor.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppValue.radius),
borderSide: const BorderSide(color: AppColor.error),
),
filled: true,
fillColor: AppColor.backgroundLight,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppValue.radius),
),
),
),
); );
} }

View File

@ -0,0 +1,28 @@
class AppValidator {
static String? validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'Email wajib diisi';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) {
return 'Format email tidak valid';
}
return null;
}
static String? validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'Password wajib diisi';
}
if (value.length < 8) {
return 'Password minimal 8 karakter';
}
// if (!RegExp(r'[A-Z]').hasMatch(value)) {
// return 'Password harus mengandung huruf besar';
// }
// if (!RegExp(r'[0-9]').hasMatch(value)) {
// return 'Password harus mengandung angka';
// }
return null;
}
}

View File

@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import '../../../common/theme/theme.dart';
import '../spacer/spacer.dart';
part 'elevated_button.dart';

View File

@ -0,0 +1,67 @@
part of 'button.dart';
class AppElevatedButton extends StatelessWidget {
const AppElevatedButton({
super.key,
required this.text,
required this.isLoading,
required this.onPressed,
this.height = 50,
});
final String text;
final bool isLoading;
final Function()? onPressed;
final double height;
@override
Widget build(BuildContext context) {
return Container(
height: height,
decoration: BoxDecoration(
gradient: const LinearGradient(colors: AppColor.primaryGradient),
borderRadius: BorderRadius.circular(AppValue.radius),
boxShadow: [
BoxShadow(
color: AppColor.primaryWithOpacity(0.3),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: isLoading
? Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Loading',
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.white,
),
),
SpaceWidth(8),
SpinKitCircle(color: AppColor.white, size: 24.0),
],
)
: Text(
text,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.white,
),
),
),
);
}
}

View File

@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:line_icons/line_icon.dart';
import 'package:line_icons/line_icons.dart';
import '../../../common/theme/theme.dart';
import '../spacer/spacer.dart';
part 'password_text_form_field.dart';
part 'text_form_field.dart';

View File

@ -0,0 +1,69 @@
part of 'field.dart';
class AppPasswordTextFormField extends StatefulWidget {
const AppPasswordTextFormField({
super.key,
this.controller,
required this.title,
this.hintText,
required this.prefixIcon,
this.validator,
});
final TextEditingController? controller;
final String title;
final String? hintText;
final IconData prefixIcon;
final String? Function(String?)? validator;
@override
State<AppPasswordTextFormField> createState() =>
_AppPasswordTextFormFieldState();
}
class _AppPasswordTextFormFieldState extends State<AppPasswordTextFormField> {
bool _obscurePassword = true;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
const SpaceHeight(8),
TextFormField(
controller: widget.controller,
keyboardType: TextInputType.emailAddress,
cursorColor: AppColor.primary,
obscureText: _obscurePassword,
style: AppStyle.md.copyWith(color: AppColor.textPrimary),
decoration: InputDecoration(
hintText: widget.hintText,
prefixIcon: LineIcon(
widget.prefixIcon,
color: AppColor.textSecondary,
),
suffixIcon: IconButton(
padding: EdgeInsets.zero,
icon: Icon(
_obscurePassword ? LineIcons.eye : LineIcons.eyeSlash,
color: AppColor.textSecondary,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: widget.validator,
),
],
);
}
}

View File

@ -0,0 +1,46 @@
part of 'field.dart';
class AppTextFormField extends StatelessWidget {
const AppTextFormField({
super.key,
this.controller,
required this.title,
this.hintText,
required this.prefixIcon,
this.validator,
});
final TextEditingController? controller;
final String title;
final String? hintText;
final IconData prefixIcon;
final String? Function(String?)? validator;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
const SpaceHeight(8),
TextFormField(
controller: controller,
keyboardType: TextInputType.emailAddress,
cursorColor: AppColor.primary,
style: AppStyle.md.copyWith(color: AppColor.textPrimary),
decoration: InputDecoration(
hintText: hintText,
prefixIcon: LineIcon(prefixIcon, color: AppColor.textSecondary),
),
validator: validator,
),
],
);
}
}

View File

@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
class SpaceHeight extends StatelessWidget {
const SpaceHeight(this.height, {super.key});
final double height;
@override
Widget build(BuildContext context) {
return SizedBox(height: height);
}
}
class SpaceWidth extends StatelessWidget {
const SpaceWidth(this.width, {super.key});
final double width;
@override
Widget build(BuildContext context) {
return SizedBox(width: width);
}
}

View File

@ -0,0 +1,217 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/button/button.dart';
import '../../../components/spacer/spacer.dart';
import 'widgets/email_field.dart';
import 'widgets/password_field.dart';
@RoutePage()
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> with TickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
late AnimationController _fadeController;
late AnimationController _slideController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_fadeController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _fadeController, curve: Curves.easeInOut),
);
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic),
);
_emailController.text = 'test@example.com';
_passwordController.text = 'password';
_fadeController.forward();
_slideController.forward();
}
@override
void dispose() {
_fadeController.dispose();
_slideController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (_formKey.currentState!.validate()) {
setState(() {
_isLoading = true;
});
// Simulasi proses login
await Future.delayed(const Duration(seconds: 2));
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppColor.primaryGradient,
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: AppValue.padding),
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLogo(),
SpaceHeight(48),
_buildLoginCard(),
SpaceHeight(24),
_buildFooter(),
],
),
),
),
),
),
),
),
);
}
Widget _buildLogo() {
return Column(
children: [
Text(
'Welcome Back',
style: AppStyle.h1.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.white,
),
),
const SpaceHeight(8),
Text(
'Sign in to your account',
style: AppStyle.lg.copyWith(color: AppColor.textLight),
),
],
);
}
Widget _buildLoginCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: AppColor.black.withOpacity(0.1),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LoginEmailField(controller: _emailController),
const SpaceHeight(24),
LoginPasswordField(controller: _passwordController),
const SpaceHeight(16),
_buildForgetPassword(),
const SpaceHeight(32),
_buildLoginButton(),
],
),
),
);
}
Widget _buildForgetPassword() {
return Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () {},
child: Text(
'Forgot Password?',
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
),
);
}
Widget _buildLoginButton() {
return AppElevatedButton(
text: 'Sign In',
isLoading: _isLoading,
onPressed: _isLoading ? null : _handleLogin,
);
}
Widget _buildFooter() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Don't have an account? ",
style: AppStyle.md.copyWith(color: AppColor.textLight),
),
GestureDetector(
onTap: () {},
child: Text(
'Sign Up',
style: AppStyle.md.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
),
],
);
}
}

View File

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:line_icons/line_icons.dart';
import '../../../../../common/validator/validator.dart';
import '../../../../components/field/field.dart';
class LoginEmailField extends StatelessWidget {
final TextEditingController? controller;
const LoginEmailField({super.key, this.controller});
@override
Widget build(BuildContext context) {
return AppTextFormField(
title: 'Email',
hintText: 'Enter your email',
prefixIcon: LineIcons.envelope,
validator: AppValidator.validateEmail,
controller: controller,
);
}
}

View File

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:line_icons/line_icons.dart';
import '../../../../../common/validator/validator.dart';
import '../../../../components/field/field.dart';
class LoginPasswordField extends StatelessWidget {
final TextEditingController controller;
const LoginPasswordField({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return AppPasswordTextFormField(
title: 'Password',
prefixIcon: LineIcons.lock,
hintText: 'Enter your password',
validator: AppValidator.validatePassword,
controller: controller,
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../common/theme/theme.dart'; import '../../common/theme/theme.dart';
import '../components/assets/assets.gen.dart'; import '../components/assets/assets.gen.dart';
import '../router/app_router.gr.dart';
@RoutePage() @RoutePage()
class SplashPage extends StatefulWidget { class SplashPage extends StatefulWidget {
@ -79,7 +80,7 @@ class _SplashPageState extends State<SplashPage> with TickerProviderStateMixin {
void _navigateToHome() { void _navigateToHome() {
// Uncomment dan sesuaikan dengan route yang ada // Uncomment dan sesuaikan dengan route yang ada
// context.router.replace(const HomeRoute()); context.router.replace(const LoginRoute());
} }
@override @override

View File

@ -7,5 +7,8 @@ class AppRouter extends RootStackRouter {
List<AutoRoute> get routes => [ List<AutoRoute> get routes => [
// Splash // Splash
AutoRoute(page: SplashRoute.page, initial: true), AutoRoute(page: SplashRoute.page, initial: true),
// Auth
AutoRoute(page: LoginRoute.page),
]; ];
} }

View File

@ -9,22 +9,40 @@
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:apskel_owner_flutter/presentation/pages/splash_page.dart' import 'package:apskel_owner_flutter/presentation/pages/auth/login/login_page.dart'
as _i1; as _i1;
import 'package:auto_route/auto_route.dart' as _i2; import 'package:apskel_owner_flutter/presentation/pages/splash_page.dart'
as _i2;
import 'package:auto_route/auto_route.dart' as _i3;
/// generated route for /// generated route for
/// [_i1.SplashPage] /// [_i1.LoginPage]
class SplashRoute extends _i2.PageRouteInfo<void> { class LoginRoute extends _i3.PageRouteInfo<void> {
const SplashRoute({List<_i2.PageRouteInfo>? children}) const LoginRoute({List<_i3.PageRouteInfo>? children})
: super(LoginRoute.name, initialChildren: children);
static const String name = 'LoginRoute';
static _i3.PageInfo page = _i3.PageInfo(
name,
builder: (data) {
return const _i1.LoginPage();
},
);
}
/// generated route for
/// [_i2.SplashPage]
class SplashRoute extends _i3.PageRouteInfo<void> {
const SplashRoute({List<_i3.PageRouteInfo>? children})
: super(SplashRoute.name, initialChildren: children); : super(SplashRoute.name, initialChildren: children);
static const String name = 'SplashRoute'; static const String name = 'SplashRoute';
static _i2.PageInfo page = _i2.PageInfo( static _i3.PageInfo page = _i3.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i1.SplashPage(); return const _i2.SplashPage();
}, },
); );
} }

View File

@ -366,6 +366,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_spinkit:
dependency: "direct main"
description:
name: flutter_spinkit
sha256: "77850df57c00dc218bfe96071d576a8babec24cf58b2ed121c83cca4a2fdce7f"
url: "https://pub.dev"
source: hosted
version: "5.2.2"
flutter_svg: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@ -560,6 +568,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.1"
line_icons:
dependency: "direct main"
description:
name: line_icons
sha256: "249d781d922f5437ac763d9c8f5a02cf5b499a6dc3f85e4b92e074cff0a932ab"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
lints: lints:
dependency: transitive dependency: transitive
description: description:

View File

@ -28,6 +28,8 @@ dependencies:
json_annotation: ^4.9.0 json_annotation: ^4.9.0
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
awesome_dio_interceptor: ^1.3.0 awesome_dio_interceptor: ^1.3.0
line_icons: ^2.0.3
flutter_spinkit: ^5.2.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: