MIF_E31231708/cv_app/mobile_app/lib/fitur/auth/auth.dart

708 lines
22 KiB
Dart

part of '../../main.dart';
class UserSession {
const UserSession({
required this.id,
required this.fullName,
required this.username,
});
factory UserSession.fromJson(Map<String, dynamic> json) {
return UserSession(
id:
json['id'] is int
? json['id'] as int
: int.parse(json['id'].toString()),
fullName: json['full_name']?.toString() ?? '',
username: json['username']?.toString() ?? '',
);
}
final int id;
final String fullName;
final String username;
Map<String, dynamic> toJson() {
return {'id': id, 'full_name': fullName, 'username': username};
}
}
class AuthGate extends StatefulWidget {
const AuthGate({super.key});
@override
State<AuthGate> createState() => _AuthGateState();
}
class _AuthGateState extends State<AuthGate> {
static const _sessionPreferenceKey = 'confivoice_user_session';
UserSession? _session;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSession();
}
Future<void> _loadSession() async {
try {
final preferences = await SharedPreferences.getInstance();
final sessionJson = preferences.getString(_sessionPreferenceKey);
if (sessionJson != null && sessionJson.isNotEmpty) {
_session = UserSession.fromJson(
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
);
}
} catch (_) {
_session = null;
}
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
Future<void> _setSession(UserSession session) async {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(
_sessionPreferenceKey,
jsonEncode(session.toJson()),
);
if (mounted) {
setState(() {
_session = session;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
showCompactSnackBar(
context,
message: 'Berhasil masuk, ${session.fullName}',
icon: Icons.check_circle_outline,
);
}
});
}
}
Future<void> _logout() async {
final preferences = await SharedPreferences.getInstance();
await preferences.remove(_sessionPreferenceKey);
if (mounted) {
setState(() {
_session = null;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
showCompactSnackBar(context, message: 'Berhasil logout');
}
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final session = _session;
if (session == null) {
return AuthPage(onAuthenticated: _setSession);
}
return ConfiVoiceShell(session: session, onLogout: _logout);
}
}
class AuthPage extends StatefulWidget {
const AuthPage({super.key, required this.onAuthenticated});
final ValueChanged<UserSession> onAuthenticated;
@override
State<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
final TextEditingController _apiController = TextEditingController(
text: defaultApiEndpoint(),
);
final TextEditingController _fullNameController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
bool _isRegister = false;
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadApiEndpoint();
}
@override
void dispose() {
_apiController.dispose();
_fullNameController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _loadApiEndpoint() async {
try {
final preferences = await SharedPreferences.getInstance();
final endpoint = normalizeApiEndpoint(
preferences.getString(_apiEndpointPreferenceKey) ?? '',
);
if (endpoint.isNotEmpty && mounted) {
setState(() {
_apiController.text = endpoint;
});
}
} catch (_) {}
}
Future<void> _saveApiEndpoint() async {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(
_apiEndpointPreferenceKey,
_apiController.text.trim(),
);
}
Uri? _authEndpoint(String path) {
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
if (predictEndpoint == null ||
!predictEndpoint.hasScheme ||
predictEndpoint.host.isEmpty) {
return null;
}
return predictEndpoint.replace(path: path, queryParameters: {});
}
Future<void> _submit() async {
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
if (endpoint == null) {
setState(() {
_errorMessage = 'Endpoint API tidak valid.';
});
return;
}
final username = _usernameController.text.trim();
final password = _passwordController.text;
final confirmPassword = _confirmPasswordController.text;
final fullName = _fullNameController.text.trim();
if (username.isEmpty ||
password.isEmpty ||
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
setState(() {
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
});
return;
}
if (_isRegister && password != confirmPassword) {
setState(() {
_errorMessage = 'Konfirmasi password belum sama.';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final body = <String, dynamic>{
'username': username,
'password': password,
if (_isRegister) 'full_name': fullName,
};
final response = await http
.post(
endpoint,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
final decoded = jsonDecode(response.body);
final detail =
decoded is Map ? decoded['detail']?.toString() : response.body;
throw Exception(detail ?? response.body);
}
await _saveApiEndpoint();
if (_isRegister) {
if (!mounted) {
return;
}
final registeredUsername = username;
setState(() {
_isRegister = false;
_fullNameController.clear();
_usernameController.text = registeredUsername;
_passwordController.clear();
_confirmPasswordController.clear();
_errorMessage = null;
});
showCompactSnackBar(
context,
message: 'Registrasi berhasil. Silakan login.',
icon: Icons.check_circle_outline,
);
return;
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final user = UserSession.fromJson(
(decoded['user'] as Map).cast<String, dynamic>(),
);
widget.onAuthenticated(user);
} catch (error) {
setState(() {
_errorMessage =
_isRegister
? _cleanAuthErrorMessage(error)
: 'Username atau password salah.';
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
String _cleanAuthErrorMessage(Object error) {
final message = error.toString();
const exceptionPrefix = 'Exception: ';
return message.startsWith(exceptionPrefix)
? message.substring(exceptionPrefix.length)
: message;
}
InputDecoration _authFieldDecoration({
required String label,
required IconData icon,
Widget? suffixIcon,
}) {
return InputDecoration(
filled: true,
fillColor: Colors.white,
labelText: label,
labelStyle: const TextStyle(color: Color(0xFF536179)),
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
suffixIcon: suffixIcon,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.recording),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.recording),
),
);
}
Widget _buildModeSwitch() {
return Container(
height: 46,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFEAF2FF),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFD6E3F7)),
),
child: Stack(
children: [
AnimatedAlign(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
alignment:
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: 0.5,
child: Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFF102A56).withValues(alpha: 0.22),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
),
),
),
Row(
children: [
_ModeButton(
label: 'Masuk',
isSelected: !_isRegister,
onTap: () {
setState(() {
_isRegister = false;
_errorMessage = null;
_confirmPasswordController.clear();
});
},
),
_ModeButton(
label: 'Daftar',
isSelected: _isRegister,
onTap: () {
setState(() {
_isRegister = true;
_errorMessage = null;
});
},
),
],
),
],
),
);
}
Widget _buildAuthForm() {
return SizedBox(
height: 270,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 260),
transitionBuilder: (child, animation) {
final offset = Tween<Offset>(
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
return FadeTransition(
opacity: animation,
child: SlideTransition(position: offset, child: child),
);
},
child:
_isRegister
? Column(
key: const ValueKey('register-form'),
children: [
TextField(
controller: _fullNameController,
style: const TextStyle(color: Color(0xFF0F172A)),
decoration: _authFieldDecoration(
label: 'Nama lengkap',
icon: Icons.badge_outlined,
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _usernameController,
style: const TextStyle(color: Color(0xFF0F172A)),
decoration: _authFieldDecoration(
label: 'Username',
icon: Icons.person_outline,
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
_PasswordField(
controller: _passwordController,
obscureText: _obscurePassword,
label: 'Password',
icon: Icons.lock_outline,
onToggle: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
onSubmitted: (_) => _submit(),
decorationBuilder: _authFieldDecoration,
),
const SizedBox(height: 12),
_PasswordField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
label: 'Konfirmasi password',
icon: Icons.lock_reset_outlined,
onToggle: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
onSubmitted: (_) => _submit(),
decorationBuilder: _authFieldDecoration,
),
],
)
: Column(
key: const ValueKey('login-form'),
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _usernameController,
style: const TextStyle(color: Color(0xFF0F172A)),
decoration: _authFieldDecoration(
label: 'Username',
icon: Icons.person_outline,
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
_PasswordField(
controller: _passwordController,
obscureText: _obscurePassword,
label: 'Password',
icon: Icons.lock_outline,
onToggle: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
onSubmitted: (_) => _submit(),
decorationBuilder: _authFieldDecoration,
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.graphic_eq_rounded,
color: Colors.white,
size: 42,
),
const SizedBox(height: 10),
const Text(
'Confivoice',
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.w800,
letterSpacing: 0.2,
),
),
const SizedBox(height: 26),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.20),
blurRadius: 28,
offset: const Offset(0, 18),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildModeSwitch(),
const SizedBox(height: 22),
_buildAuthForm(),
const SizedBox(height: 18),
SizedBox(
height: 50,
child: FilledButton(
onPressed: _isLoading ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: AppColors.navy,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child:
_isLoading
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Colors.white,
),
)
: Text(
_isRegister ? 'Daftar' : 'Masuk',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
_AuthErrorBox(message: _errorMessage!),
],
],
),
),
],
),
),
),
),
),
),
);
}
}
class _ModeButton extends StatelessWidget {
const _ModeButton({
required this.label,
required this.isSelected,
required this.onTap,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 180),
style: TextStyle(
color: isSelected ? Colors.white : const Color(0xFF0F172A),
fontSize: 16,
fontWeight: FontWeight.w800,
),
child: Text(label),
),
),
),
);
}
}
class _PasswordField extends StatelessWidget {
const _PasswordField({
required this.controller,
required this.obscureText,
required this.label,
required this.icon,
required this.onToggle,
required this.onSubmitted,
required this.decorationBuilder,
});
final TextEditingController controller;
final bool obscureText;
final String label;
final IconData icon;
final VoidCallback onToggle;
final ValueChanged<String> onSubmitted;
final InputDecoration Function({
required String label,
required IconData icon,
Widget? suffixIcon,
})
decorationBuilder;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
obscureText: obscureText,
style: const TextStyle(color: Color(0xFF0F172A)),
decoration: decorationBuilder(
label: label,
icon: icon,
suffixIcon: IconButton(
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
onPressed: onToggle,
icon: Icon(
obscureText
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: AppColors.navy,
),
),
),
onSubmitted: onSubmitted,
);
}
}
class _AuthErrorBox extends StatelessWidget {
const _AuthErrorBox({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF7ED),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFBBF24)),
),
child: Text(
message,
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
),
);
}
}