part of '../../main.dart'; class UserSession { const UserSession({ required this.id, required this.fullName, required this.username, }); factory UserSession.fromJson(Map 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 toJson() { return {'id': id, 'full_name': fullName, 'username': username}; } } class AuthGate extends StatefulWidget { const AuthGate({super.key}); @override State createState() => _AuthGateState(); } class _AuthGateState extends State { static const _sessionPreferenceKey = 'confivoice_user_session'; UserSession? _session; bool _isLoading = true; @override void initState() { super.initState(); _loadSession(); } Future _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(), ); } } catch (_) { _session = null; } if (mounted) { setState(() { _isLoading = false; }); } } Future _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 _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 onAuthenticated; @override State createState() => _AuthPageState(); } class _AuthPageState extends State { 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 _loadApiEndpoint() async { try { final preferences = await SharedPreferences.getInstance(); final endpoint = normalizeApiEndpoint( preferences.getString(_apiEndpointPreferenceKey) ?? '', ); if (endpoint.isNotEmpty && mounted) { setState(() { _apiController.text = endpoint; }); } } catch (_) {} } Future _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 _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 = { '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; final user = UserSession.fromJson( (decoded['user'] as Map).cast(), ); 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( 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 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), ), ); } }