708 lines
22 KiB
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),
|
|
),
|
|
);
|
|
}
|
|
}
|