commit e0839a7237cd129f2e2e97ab70fe8e4e014338bc Author: 1rHub Date: Thu Aug 6 15:22:07 2026 +0700 Menambahkan project TA diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01fc373 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +__pycache__/ +*.py[cod] +.venv/ +.streamlit/secrets.toml +build/ +.dart_tool/ +.gradle/ +*.iml +cv_app/mobile_app/build/ +cv_app/mobile_app/.dart_tool/ +cv_app/mobile_app/android/.gradle/ +cv_web/data/*.db +cv_app/data/*.db diff --git a/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260608222936.xml b/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260608222936.xml new file mode 100644 index 0000000..df64f9e --- /dev/null +++ b/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260608222936.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260626234244.xml b/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260626234244.xml new file mode 100644 index 0000000..df64f9e --- /dev/null +++ b/.history/cv_app/mobile_app/android/app/src/main/AndroidManifest_20260626234244.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235309.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235309.dart new file mode 100644 index 0000000..e69de29 diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235342.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235342.dart new file mode 100644 index 0000000..53cb8ef --- /dev/null +++ b/.history/cv_app/mobile_app/lib/copilot_20260701235342.dart @@ -0,0 +1,3 @@ +class LoginScreen extends StatefulWidget { + + \ No newline at end of file diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235642.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235642.dart new file mode 100644 index 0000000..e69de29 diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235646.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235646.dart new file mode 100644 index 0000000..3064a65 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/copilot_20260701235646.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +class LoginScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + \ No newline at end of file diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235650.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235650.dart new file mode 100644 index 0000000..e69de29 diff --git a/.history/cv_app/mobile_app/lib/copilot_20260701235651.dart b/.history/cv_app/mobile_app/lib/copilot_20260701235651.dart new file mode 100644 index 0000000..3064a65 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/copilot_20260701235651.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +class LoginScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + \ No newline at end of file diff --git a/.history/cv_app/mobile_app/lib/copilot_20260702000412.dart b/.history/cv_app/mobile_app/lib/copilot_20260702000412.dart new file mode 100644 index 0000000..e69de29 diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260621154154.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260621154154.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260621154154.dart @@ -0,0 +1,707 @@ +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), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225159.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225159.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225159.dart @@ -0,0 +1,707 @@ +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), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225315.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225315.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260623225315.dart @@ -0,0 +1,707 @@ +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), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624054320.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624054320.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624054320.dart @@ -0,0 +1,707 @@ +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), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624223837.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624223837.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260624223837.dart @@ -0,0 +1,707 @@ +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), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235026.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235026.dart new file mode 100644 index 0000000..cdd68f1 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235026.dart @@ -0,0 +1,709 @@ +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), + ), + ); + } +} + +// create a login form with email and password validation \ No newline at end of file diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235027.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235027.dart new file mode 100644 index 0000000..66164f9 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235027.dart @@ -0,0 +1,709 @@ +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), + ), + ); + } +} + +// create a login form with email and password validation diff --git a/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235256.dart b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235256.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/auth/auth_20260701235256.dart @@ -0,0 +1,707 @@ +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), + ), + ); + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621152900.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621152900.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621152900.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155257.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155257.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155257.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155258.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155258.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155258.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155259.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155259.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155259.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155303.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155303.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155303.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155311.dart b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155311.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/.history/cv_app/mobile_app/lib/fitur/prediction/prediction_page_20260621155311.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615201942.dart b/.history/cv_app/mobile_app/lib/main_20260615201942.dart new file mode 100644 index 0000000..d4c9dc2 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615201942.dart @@ -0,0 +1,2004 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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; + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + } + } + + @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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final VoidCallback onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: widget.onLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615202635.dart b/.history/cv_app/mobile_app/lib/main_20260615202635.dart new file mode 100644 index 0000000..ae902d7 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615202635.dart @@ -0,0 +1,2004 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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; + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + } + } + + @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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = '$error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final VoidCallback onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: widget.onLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615202727.dart b/.history/cv_app/mobile_app/lib/main_20260615202727.dart new file mode 100644 index 0000000..d4c9dc2 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615202727.dart @@ -0,0 +1,2004 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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; + }); + } + } + + Future _logout() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(_sessionPreferenceKey); + if (mounted) { + setState(() { + _session = null; + }); + } + } + + @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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final VoidCallback onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: widget.onLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204019.dart b/.history/cv_app/mobile_app/lib/main_20260615204019.dart new file mode 100644 index 0000000..66e098a --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204019.dart @@ -0,0 +1,2087 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204022.dart b/.history/cv_app/mobile_app/lib/main_20260615204022.dart new file mode 100644 index 0000000..e7e0c90 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204022.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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', + icon: Icons.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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204035.dart b/.history/cv_app/mobile_app/lib/main_20260615204035.dart new file mode 100644 index 0000000..f0e4a46 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204035.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204040.dart b/.history/cv_app/mobile_app/lib/main_20260615204040.dart new file mode 100644 index 0000000..e7e0c90 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204040.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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', + icon: Icons.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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204048.dart b/.history/cv_app/mobile_app/lib/main_20260615204048.dart new file mode 100644 index 0000000..66e098a --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204048.dart @@ -0,0 +1,2087 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204050.dart b/.history/cv_app/mobile_app/lib/main_20260615204050.dart new file mode 100644 index 0000000..a28e08d --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204050.dart @@ -0,0 +1,2087 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204051.dart b/.history/cv_app/mobile_app/lib/main_20260615204051.dart new file mode 100644 index 0000000..66e098a --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204051.dart @@ -0,0 +1,2087 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204054.dart b/.history/cv_app/mobile_app/lib/main_20260615204054.dart new file mode 100644 index 0000000..e7e0c90 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204054.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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', + icon: Icons.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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 18), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204139.dart b/.history/cv_app/mobile_app/lib/main_20260615204139.dart new file mode 100644 index 0000000..7acf447 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204139.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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', + icon: Icons.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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204209.dart b/.history/cv_app/mobile_app/lib/main_20260615204209.dart new file mode 100644 index 0000000..923b6ee --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204209.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204211.dart b/.history/cv_app/mobile_app/lib/main_20260615204211.dart new file mode 100644 index 0000000..cd4f433 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204211.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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', + x + ); + } + }); + } + } + + @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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204214.dart b/.history/cv_app/mobile_app/lib/main_20260615204214.dart new file mode 100644 index 0000000..7acf447 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204214.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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', + icon: Icons.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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204218.dart b/.history/cv_app/mobile_app/lib/main_20260615204218.dart new file mode 100644 index 0000000..0714eb5 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204218.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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', + icon: Icons.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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 1), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615204221.dart b/.history/cv_app/mobile_app/lib/main_20260615204221.dart new file mode 100644 index 0000000..7acf447 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615204221.dart @@ -0,0 +1,2088 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + required IconData icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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', + icon: Icons.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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + } + + @override + void dispose() { + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + selectedAudio: _selectedAudio, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.selectedAudio, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final SelectedAudio? selectedAudio; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: studentNameController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + labelText: 'Nama siswa/i', + ), + textInputAction: TextInputAction.next, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 120, child: WaveformPreview(values: wavePreview)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values}); + + final List values; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _WaveformPainter( + values: values, + color: Theme.of(context).colorScheme.primary, + ), + child: + values.isEmpty + ? const Center( + child: Text( + 'Preview waveform akan muncul setelah audio dipilih.', + ), + ) + : null, + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223258.dart b/.history/cv_app/mobile_app/lib/main_20260615223258.dart new file mode 100644 index 0000000..a6e4d18 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223258.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223308.dart b/.history/cv_app/mobile_app/lib/main_20260615223308.dart new file mode 100644 index 0000000..69c752f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223308.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).titleLargzzzzze, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223311.dart b/.history/cv_app/mobile_app/lib/main_20260615223311.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223311.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223346.dart b/.history/cv_app/mobile_app/lib/main_20260615223346.dart new file mode 100644 index 0000000..358bea5 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223346.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223416.dart b/.history/cv_app/mobile_app/lib/main_20260615223416.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223416.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223418.dart b/.history/cv_app/mobile_app/lib/main_20260615223418.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223418.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223432.dart b/.history/cv_app/mobile_app/lib/main_20260615223432.dart new file mode 100644 index 0000000..358bea5 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223432.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223437.dart b/.history/cv_app/mobile_app/lib/main_20260615223437.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223437.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223442.dart b/.history/cv_app/mobile_app/lib/main_20260615223442.dart new file mode 100644 index 0000000..3f17bfd --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223442.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12) + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260615223449.dart b/.history/cv_app/mobile_app/lib/main_20260615223449.dart new file mode 100644 index 0000000..572a84f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260615223449.dart @@ -0,0 +1,2395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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); + } + + final decoded = jsonDecode(response.body) as Map; + final user = UserSession.fromJson( + (decoded['user'] as Map).cast(), + ); + await _saveApiEndpoint(); + widget.onAuthenticated(user); + } catch (error) { + setState(() { + _errorMessage = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _loadApiEndpoint(); + _loadStudentNames(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => + 'confivoice_student_names_${widget.session.id}'; + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final names = preferences.getStringList(_studentNamesPreferenceKey) ?? []; + if (!mounted) { + return; + } + setState(() { + _studentNames = _normalizeStudentNames(names); + }); + } catch (_) {} + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(prediction.toSaveJson(widget.session.id)), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'user_id': widget.session.id.toString()}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: 92, + child: Text( + widget.session.fullName, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + ), + ), + ), + ), + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + IconButton( + tooltip: 'Logout', + onPressed: _confirmLogout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Pengaturan API'), + content: TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text(analysis.studentName), + subtitle: Text( + '${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId) { + return {...rawJson, 'student_name': studentName, 'user_id': userId}; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + _focusNode.unfocus(); + setState(() { + _query = name; + }); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + ListTile( + dense: true, + leading: const Icon( + Icons.person_outline, + color: AppColors.blueSoft, + ), + title: Text(name), + onTap: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + ListTile( + dense: true, + leading: const Icon( + Icons.add_circle_outline, + color: AppColors.blueSoft, + ), + title: Text('Tambah "$cleanQuery"'), + onTap: _addCurrentName, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final confidencePercent = (result.confidence * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$confidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatelessWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + Widget build(BuildContext context) { + final emptyText = + isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: values, + color: + isLive + ? AppColors.blueSoft + : Theme.of(context).colorScheme.primary, + isLive: isLive, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({ + required this.values, + required this.color, + required this.isLive, + }); + + final List values; + final Color color; + final bool isLive; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = + isLive ? AppColors.blue.withValues(alpha: 0.18) : AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final glowPaint = + Paint() + ..color = color.withValues(alpha: isLive ? 0.18 : 0.0) + ..strokeWidth = max(6, size.width / values.length * 0.85) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + if (isLive) { + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + } + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || + oldDelegate.color != color || + oldDelegate.isLive != isLive; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260618122315.dart b/.history/cv_app/mobile_app/lib/main_20260618122315.dart new file mode 100644 index 0000000..2a06f2b --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260618122315.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620005846.dart b/.history/cv_app/mobile_app/lib/main_20260620005846.dart new file mode 100644 index 0000000..26835bc --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620005846.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%',f + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010031.dart b/.history/cv_app/mobile_app/lib/main_20260620010031.dart new file mode 100644 index 0000000..2a06f2b --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010031.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010032.dart b/.history/cv_app/mobile_app/lib/main_20260620010032.dart new file mode 100644 index 0000000..f34c494 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010032.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%' + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010035.dart b/.history/cv_app/mobile_app/lib/main_20260620010035.dart new file mode 100644 index 0000000..8bd52ad --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010035.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent% + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010042.dart b/.history/cv_app/mobile_app/lib/main_20260620010042.dart new file mode 100644 index 0000000..d60ce2f --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010042.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%" + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010044.dart b/.history/cv_app/mobile_app/lib/main_20260620010044.dart new file mode 100644 index 0000000..8bd52ad --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010044.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent% + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010045.dart b/.history/cv_app/mobile_app/lib/main_20260620010045.dart new file mode 100644 index 0000000..f34c494 --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010045.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%' + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260620010046.dart b/.history/cv_app/mobile_app/lib/main_20260620010046.dart new file mode 100644 index 0000000..2a06f2b --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260620010046.dart @@ -0,0 +1,2777 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} + +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: 'http://MacBook-Pro-2.local:8000/predict', + ); + 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 = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + 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 = 'Gagal masuk: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + 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), + ), + ); + } +} + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: 'http://MacBook-Pro-2.local:8000/predict', + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.dispose(); + super.dispose(); + } + + Future _loadApiEndpoint() async { + try { + final preferences = await SharedPreferences.getInstance(); + final endpoint = + preferences.getString(_apiEndpointPreferenceKey)?.trim() ?? ''; + if (endpoint.isNotEmpty && mounted) { + setState(() { + _apiController.text = endpoint; + }); + } + } catch (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final savedResultNames = await _loadStudentNamesFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultNames, + ]); + await preferences.setStringList(_studentNamesPreferenceKey, names); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + }); + } catch (_) {} + } + + Future> _loadStudentNamesFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const []; + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map((item) => (item as Map)['student_name']?.toString() ?? '') + .where((name) => _cleanStudentName(name).isNotEmpty) + .toList(); + } catch (_) { + return const []; + } + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + Future _rememberStudentName(String name) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + if (mounted) { + setState(() { + _studentNames = updated; + _studentNameController.text = cleanName; + }); + } + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: $responseBody'); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName(prediction.studentName); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + trailing: Text('${analysis.confidencePercent}%'), + ), + ); + }, + ), + ); + } +} + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({required this.value, required this.onChanged}); + + final String value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/.history/cv_app/mobile_app/lib/main_20260621041203.dart b/.history/cv_app/mobile_app/lib/main_20260621041203.dart new file mode 100644 index 0000000..3d375bc --- /dev/null +++ b/.history/cv_app/mobile_app/lib/main_20260621041203.dart @@ -0,0 +1,21 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +part 'app/app.dart'; +part 'fitur/auth/auth.dart'; +part 'fitur/home/confivoice_shell.dart'; +part 'fitur/saved_analyses/saved_analyses_page.dart'; +part 'models/analysis_models.dart'; +part 'fitur/prediction/prediction_page.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} diff --git a/.history/cv_web/app_20260615001457.py b/.history/cv_web/app_20260615001457.py new file mode 100644 index 0000000..2f4f262 --- /dev/null +++ b/.history/cv_web/app_20260615001457.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak percaya diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001816.py b/.history/cv_web/app_20260615001816.py new file mode 100644 index 0000000..bf7cb01 --- /dev/null +++ b/.history/cv_web/app_20260615001816.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak percaya diri{tpd_count}
+
Rata-rata PercayaD{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001819.py b/.history/cv_web/app_20260615001819.py new file mode 100644 index 0000000..41f76a0 --- /dev/null +++ b/.history/cv_web/app_20260615001819.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak percaya diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001824.py b/.history/cv_web/app_20260615001824.py new file mode 100644 index 0000000..0030430 --- /dev/null +++ b/.history/cv_web/app_20260615001824.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak ercaya diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001825.py b/.history/cv_web/app_20260615001825.py new file mode 100644 index 0000000..42a8173 --- /dev/null +++ b/.history/cv_web/app_20260615001825.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak Percaya diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001828.py b/.history/cv_web/app_20260615001828.py new file mode 100644 index 0000000..cc4b275 --- /dev/null +++ b/.history/cv_web/app_20260615001828.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001833.py b/.history/cv_web/app_20260615001833.py new file mode 100644 index 0000000..876ffd4 --- /dev/null +++ b/.history/cv_web/app_20260615001833.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615001835.py b/.history/cv_web/app_20260615001835.py new file mode 100644 index 0000000..c7d5321 --- /dev/null +++ b/.history/cv_web/app_20260615001835.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615003441.py b/.history/cv_web/app_20260615003441.py new file mode 100644 index 0000000..566bf93 --- /dev/null +++ b/.history/cv_web/app_20260615003441.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615003446.py b/.history/cv_web/app_20260615003446.py new file mode 100644 index 0000000..c7d5321 --- /dev/null +++ b/.history/cv_web/app_20260615003446.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260615003447.py b/.history/cv_web/app_20260615003447.py new file mode 100644 index 0000000..c7d5321 --- /dev/null +++ b/.history/cv_web/app_20260615003447.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260618000217.py b/.history/cv_web/app_20260618000217.py new file mode 100644 index 0000000..2bfee9d --- /dev/null +++ b/.history/cv_web/app_20260618000217.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260618000256.py b/.history/cv_web/app_20260618000256.py new file mode 100644 index 0000000..c7d5321 --- /dev/null +++ b/.history/cv_web/app_20260618000256.py @@ -0,0 +1,600 @@ +from html import escape +from pathlib import Path +import sys + +from fastapi import FastAPI, Form, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + delete_prediction_result, + get_database_label, + get_prediction_result, + init_db, + list_prediction_results, + save_prediction_result, + update_prediction_result, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page(): + rows = list_prediction_results(limit=500) + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = sum(float(row["probability_pd"] or 0) for row in rows) / total if total else 0 + + return _html_page( + title="Admin ConfiVoice", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Tambah Data

+

Gunakan form ini jika perlu menambahkan hasil analisis secara manual.

+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+
+
+
+

Data Analisis

+

Kelola data yang sudah tersimpan.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _html_page(title, content): + return f""" + + + + + + {escape(title)} + + + + {content} + + + """ diff --git a/.history/cv_web/app_20260618014739.py b/.history/cv_web/app_20260618014739.py new file mode 100644 index 0000000..7ce096f --- /dev/null +++ b/.history/cv_web/app_20260618014739.py @@ -0,0 +1,1416 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618014757.py b/.history/cv_web/app_20260618014757.py new file mode 100644 index 0000000..fb070bd --- /dev/null +++ b/.history/cv_web/app_20260618014757.py @@ -0,0 +1,1416 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618014941.py b/.history/cv_web/app_20260618014941.py new file mode 100644 index 0000000..3aff5c1 --- /dev/null +++ b/.history/cv_web/app_20260618014941.py @@ -0,0 +1,1426 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618014956.py b/.history/cv_web/app_20260618014956.py new file mode 100644 index 0000000..fb070bd --- /dev/null +++ b/.history/cv_web/app_20260618014956.py @@ -0,0 +1,1416 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618015004.py b/.history/cv_web/app_20260618015004.py new file mode 100644 index 0000000..21ed8c7 --- /dev/null +++ b/.history/cv_web/app_20260618015004.py @@ -0,0 +1,1426 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ + """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618015018.py b/.history/cv_web/app_20260618015018.py new file mode 100644 index 0000000..fb070bd --- /dev/null +++ b/.history/cv_web/app_20260618015018.py @@ -0,0 +1,1416 @@ +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + return _html_page( + title="Logout", + active="logout", + content=""" +
+

Logout

+

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

+ Kembali ke Dashboard +
+ """, + ) + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + """ diff --git a/.history/cv_web/app_20260618021000.py b/.history/cv_web/app_20260618021000.py new file mode 100644 index 0000000..53a22b5 --- /dev/null +++ b/.history/cv_web/app_20260618021000.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk ke pane ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021004.py b/.history/cv_web/app_20260618021004.py new file mode 100644 index 0000000..888d202 --- /dev/null +++ b/.history/cv_web/app_20260618021004.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021006.py b/.history/cv_web/app_20260618021006.py new file mode 100644 index 0000000..e7cc444 --- /dev/null +++ b/.history/cv_web/app_20260618021006.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021008.py b/.history/cv_web/app_20260618021008.py new file mode 100644 index 0000000..1ae2f9b --- /dev/null +++ b/.history/cv_web/app_20260618021008.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021109.py b/.history/cv_web/app_20260618021109.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021109.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021131.py b/.history/cv_web/app_20260618021131.py new file mode 100644 index 0000000..68296b6 --- /dev/null +++ b/.history/cv_web/app_20260618021131.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021132.py b/.history/cv_web/app_20260618021132.py new file mode 100644 index 0000000..c409755 --- /dev/null +++ b/.history/cv_web/app_20260618021132.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{}}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021134.py b/.history/cv_web/app_20260618021134.py new file mode 100644 index 0000000..68296b6 --- /dev/null +++ b/.history/cv_web/app_20260618021134.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021137.py b/.history/cv_web/app_20260618021137.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021137.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021139.py b/.history/cv_web/app_20260618021139.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021139.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021141.py b/.history/cv_web/app_20260618021141.py new file mode 100644 index 0000000..812529a --- /dev/null +++ b/.history/cv_web/app_20260618021141.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021143.py b/.history/cv_web/app_20260618021143.py new file mode 100644 index 0000000..4b389f9 --- /dev/null +++ b/.history/cv_web/app_20260618021143.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{Masuk}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021209.py b/.history/cv_web/app_20260618021209.py new file mode 100644 index 0000000..812529a --- /dev/null +++ b/.history/cv_web/app_20260618021209.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021216.py b/.history/cv_web/app_20260618021216.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021216.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021420.py b/.history/cv_web/app_20260618021420.py new file mode 100644 index 0000000..eb974f4 --- /dev/null +++ b/.history/cv_web/app_20260618021420.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021440.py b/.history/cv_web/app_20260618021440.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021440.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021614.py b/.history/cv_web/app_20260618021614.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021614.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021620.py b/.history/cv_web/app_20260618021620.py new file mode 100644 index 0000000..f3e7992 --- /dev/null +++ b/.history/cv_web/app_20260618021620.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021623.py b/.history/cv_web/app_20260618021623.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021623.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021625.py b/.history/cv_web/app_20260618021625.py new file mode 100644 index 0000000..f3e7992 --- /dev/null +++ b/.history/cv_web/app_20260618021625.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021629.py b/.history/cv_web/app_20260618021629.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021629.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021632.py b/.history/cv_web/app_20260618021632.py new file mode 100644 index 0000000..f3e7992 --- /dev/null +++ b/.history/cv_web/app_20260618021632.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021634.py b/.history/cv_web/app_20260618021634.py new file mode 100644 index 0000000..18e5763 --- /dev/null +++ b/.history/cv_web/app_20260618021634.py @@ -0,0 +1,1981 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{title}

+

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618021639.py b/.history/cv_web/app_20260618021639.py new file mode 100644 index 0000000..f3e7992 --- /dev/null +++ b/.history/cv_web/app_20260618021639.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260618031442.py b/.history/cv_web/app_20260618031442.py new file mode 100644 index 0000000..8826feb --- /dev/null +++ b/.history/cv_web/app_20260618031442.py @@ -0,0 +1,1975 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="login") + + +@app.post("/admin/login") +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return _auth_page(mode="register") + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + if len(username) < 3: + return _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + if len(password) < 6: + return _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + if password != confirm_password: + return _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + + return _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + """ + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun Admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/cv_web/app_20260622073911.py b/.history/cv_web/app_20260622073911.py new file mode 100644 index 0000000..f51c1cd --- /dev/null +++ b/.history/cv_web/app_20260622073911.py @@ -0,0 +1,1993 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return HTMLResponse(_auth_page(mode="login")) + + +@app.post("/admin/login", response_class=HTMLResponse) +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return HTMLResponse( + _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return HTMLResponse(_auth_page(mode="register")) + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + ) + if len(username) < 3: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + ) + if len(password) < 6: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + ) + if password != confirm_password: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return HTMLResponse( + _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + ) + + return HTMLResponse( + _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + eye_icon_json = json.dumps(eye_icon) + eye_off_icon_json = json.dumps(eye_off_icon) + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + f""" + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun Admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/.history/project_svm_suara/app_20260524001201.py b/.history/project_svm_suara/app_20260524001201.py new file mode 100644 index 0000000..a614ef5 --- /dev/null +++ b/.history/project_svm_suara/app_20260524001201.py @@ -0,0 +1,120 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.60 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime dipakai sebagai cache key. + Jika model dilatih ulang dan file berubah, Streamlit otomatis memuat model baru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + # Semua input disiapkan ulang menjadi WAV mono 22050 Hz sebelum ekstraksi fitur. + try: + convert_to_wav(temp_input_path, temp_wav_path) + features = extract_features(temp_wav_path).reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + st.subheader("Hasil Prediksi") + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning( + "Model belum yakin, suara perlu direkam ulang atau data training perlu ditambah." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") diff --git a/.history/project_svm_suara/app_20260524002356.py b/.history/project_svm_suara/app_20260524002356.py new file mode 100644 index 0000000..ac64ccf --- /dev/null +++ b/.history/project_svm_suara/app_20260524002356.py @@ -0,0 +1,121 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime dipakai sebagai cache key. + Jika model dilatih ulang dan file berubah, Streamlit otomatis memuat model baru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + # Semua input disiapkan ulang menjadi WAV mono 22050 Hz sebelum ekstraksi fitur. + try: + convert_to_wav(temp_input_path, temp_wav_path) + features = extract_features(temp_wav_path).reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + st.subheader("Hasil Prediksi") + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning( + "Model belum yakin, suara perlu direkam ulang atau data training perlu ditambah." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") diff --git a/.history/project_svm_suara/app_20260524002543.py b/.history/project_svm_suara/app_20260524002543.py new file mode 100644 index 0000000..478ccd7 --- /dev/null +++ b/.history/project_svm_suara/app_20260524002543.py @@ -0,0 +1,116 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime dipakai sebagai cache key. + Jika model dilatih ulang dan file berubah, Streamlit otomatis memuat model baru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + # Semua input disiapkan ulang menjadi WAV mono 22050 Hz sebelum ekstraksi fitur. + try: + convert_to_wav(temp_input_path, temp_wav_path) + features = extract_features(temp_wav_path).reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + st.subheader("Hasil Prediksi") + probability_pd = probabilities.get(LABEL_PD, 0.0) +probability_tpd = probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + +if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin. Suara perlu direkam ulang atau data training perlu ditambah." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") diff --git a/.history/project_svm_suara/app_20260524002610.py b/.history/project_svm_suara/app_20260524002610.py new file mode 100644 index 0000000..7945b3f --- /dev/null +++ b/.history/project_svm_suara/app_20260524002610.py @@ -0,0 +1,113 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime dipakai sebagai cache key. + Jika model dilatih ulang dan file berubah, Streamlit otomatis memuat model baru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + # Semua input disiapkan ulang menjadi WAV mono 22050 Hz sebelum ekstraksi fitur. + try: + convert_to_wav(temp_input_path, temp_wav_path) + features = extract_features(temp_wav_path).reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + st.subheader("Hasil Prediksi") + probability_pd = probabilities.get(LABEL_PD, 0.0) +probability_tpd = probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + +if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin. Suara perlu direkam ulang atau data training perlu ditambah." + ) diff --git a/.history/project_svm_suara/app_20260524005657.py b/.history/project_svm_suara/app_20260524005657.py new file mode 100644 index 0000000..4bab8a2 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005657.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005709.py b/.history/project_svm_suara/app_20260524005709.py new file mode 100644 index 0000000..7092270 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005709.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005725.py b/.history/project_svm_suara/app_20260524005725.py new file mode 100644 index 0000000..4bab8a2 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005725.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005728.py b/.history/project_svm_suara/app_20260524005728.py new file mode 100644 index 0000000..7092270 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005728.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005741.py b/.history/project_svm_suara/app_20260524005741.py new file mode 100644 index 0000000..a31a66f --- /dev/null +++ b/.history/project_svm_suara/app_20260524005741.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005752.py b/.history/project_svm_suara/app_20260524005752.py new file mode 100644 index 0000000..7092270 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005752.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005804.py b/.history/project_svm_suara/app_20260524005804.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005804.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005816.py b/.history/project_svm_suara/app_20260524005816.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005816.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005826.py b/.history/project_svm_suara/app_20260524005826.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005826.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005833.py b/.history/project_svm_suara/app_20260524005833.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005833.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005852.py b/.history/project_svm_suara/app_20260524005852.py new file mode 100644 index 0000000..7092270 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005852.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005856.py b/.history/project_svm_suara/app_20260524005856.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005856.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005946.py b/.history/project_svm_suara/app_20260524005946.py new file mode 100644 index 0000000..d7a97cd --- /dev/null +++ b/.history/project_svm_suara/app_20260524005946.py @@ -0,0 +1,139 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005949.py b/.history/project_svm_suara/app_20260524005949.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524005949.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524005955.py b/.history/project_svm_suara/app_20260524005955.py new file mode 100644 index 0000000..19e5dac --- /dev/null +++ b/.history/project_svm_suara/app_20260524005955.py @@ -0,0 +1,137 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010002.py b/.history/project_svm_suara/app_20260524010002.py new file mode 100644 index 0000000..af5046d --- /dev/null +++ b/.history/project_svm_suara/app_20260524010002.py @@ -0,0 +1,138 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010003.py b/.history/project_svm_suara/app_20260524010003.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010003.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010330.py b/.history/project_svm_suara/app_20260524010330.py new file mode 100644 index 0000000..154b4fa --- /dev/null +++ b/.history/project_svm_suara/app_20260524010330.py @@ -0,0 +1,146 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin, silakan rekam ulang atau tambah data training." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010333.py b/.history/project_svm_suara/app_20260524010333.py new file mode 100644 index 0000000..0cae0d9 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010333.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin, silakan rekam ulang atau tambah data training." + ) + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010359.py b/.history/project_svm_suara/app_20260524010359.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010359.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010406.py b/.history/project_svm_suara/app_20260524010406.py new file mode 100644 index 0000000..0e5dfaf --- /dev/null +++ b/.history/project_svm_suara/app_20260524010406.py @@ -0,0 +1,140 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010408.py b/.history/project_svm_suara/app_20260524010408.py new file mode 100644 index 0000000..704a389 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010408.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") +if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin, silakan rekam ulang atau tambah data training." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010409.py b/.history/project_svm_suara/app_20260524010409.py new file mode 100644 index 0000000..0e5dfaf --- /dev/null +++ b/.history/project_svm_suara/app_20260524010409.py @@ -0,0 +1,140 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010411.py b/.history/project_svm_suara/app_20260524010411.py new file mode 100644 index 0000000..83afb61 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010411.py @@ -0,0 +1,140 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010412.py b/.history/project_svm_suara/app_20260524010412.py new file mode 100644 index 0000000..154b4fa --- /dev/null +++ b/.history/project_svm_suara/app_20260524010412.py @@ -0,0 +1,146 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") +else: + st.warning( + "Model belum cukup yakin, silakan rekam ulang atau tambah data training." + ) + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010415.py b/.history/project_svm_suara/app_20260524010415.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010415.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010658.py b/.history/project_svm_suara/app_20260524010658.py new file mode 100644 index 0000000..7dce970 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010658.py @@ -0,0 +1,146 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010707.py b/.history/project_svm_suara/app_20260524010707.py new file mode 100644 index 0000000..1402aa6 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010707.py @@ -0,0 +1,147 @@ +from pyexpat import model +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010708.py b/.history/project_svm_suara/app_20260524010708.py new file mode 100644 index 0000000..82acf32 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010708.py @@ -0,0 +1,147 @@ +from pyexpat import model +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list( model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010710.py b/.history/project_svm_suara/app_20260524010710.py new file mode 100644 index 0000000..7dce970 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010710.py @@ -0,0 +1,146 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010712.py b/.history/project_svm_suara/app_20260524010712.py new file mode 100644 index 0000000..b68dd31 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010712.py @@ -0,0 +1,144 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010723.py b/.history/project_svm_suara/app_20260524010723.py new file mode 100644 index 0000000..1af1167 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010723.py @@ -0,0 +1,137 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010725.py b/.history/project_svm_suara/app_20260524010725.py new file mode 100644 index 0000000..158fdb3 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010725.py @@ -0,0 +1,137 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010727.py b/.history/project_svm_suara/app_20260524010727.py new file mode 100644 index 0000000..ced9810 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010727.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010731.py b/.history/project_svm_suara/app_20260524010731.py new file mode 100644 index 0000000..179156b --- /dev/null +++ b/.history/project_svm_suara/app_20260524010731.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + lass_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010733.py b/.history/project_svm_suara/app_20260524010733.py new file mode 100644 index 0000000..f6210b0 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010733.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] +lass_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010736.py b/.history/project_svm_suara/app_20260524010736.py new file mode 100644 index 0000000..ced9810 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010736.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] +class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010737.py b/.history/project_svm_suara/app_20260524010737.py new file mode 100644 index 0000000..a0c863f --- /dev/null +++ b/.history/project_svm_suara/app_20260524010737.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] +``class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010739.py b/.history/project_svm_suara/app_20260524010739.py new file mode 100644 index 0000000..46122dc --- /dev/null +++ b/.history/project_svm_suara/app_20260524010739.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + +predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010743.py b/.history/project_svm_suara/app_20260524010743.py new file mode 100644 index 0000000..7e88925 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010743.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) +confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010746.py b/.history/project_svm_suara/app_20260524010746.py new file mode 100644 index 0000000..af0b128 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010746.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + +probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010749.py b/.history/project_svm_suara/app_20260524010749.py new file mode 100644 index 0000000..7b79e34 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010749.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) +probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010751.py b/.history/project_svm_suara/app_20260524010751.py new file mode 100644 index 0000000..9334968 --- /dev/null +++ b/.history/project_svm_suara/app_20260524010751.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) +margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010753.py b/.history/project_svm_suara/app_20260524010753.py new file mode 100644 index 0000000..e522f1d --- /dev/null +++ b/.history/project_svm_suara/app_20260524010753.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/app_20260524010756.py b/.history/project_svm_suara/app_20260524010756.py new file mode 100644 index 0000000..8a273de --- /dev/null +++ b/.history/project_svm_suara/app_20260524010756.py @@ -0,0 +1,145 @@ +import tempfile +from pathlib import Path + +import joblib +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_uploaded_file(uploaded_file, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(uploaded_file.getbuffer()) + return Path(temp_audio.name) + + +def predict_uploaded_audio(uploaded_file): + """ + Alur prediksi: + upload audio -> convert WAV mono 22050 Hz -> extract_features -> scaler+SVM pipeline. + """ + model = load_model(MODEL_PATH.stat().st_mtime) + original_extension = Path(uploaded_file.name).suffix.lower() + + if original_extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_input_path = save_uploaded_file(uploaded_file, original_extension) + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(temp_input_path, temp_wav_path) + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + } + finally: + temp_input_path.unlink(missing_ok=True) + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Upload file audio untuk memprediksi kelas PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, +) + +if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi"): + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + try: + label, confidence, probabilities, debug_info = predict_uploaded_audio(uploaded_file) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + st.stop() + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") diff --git a/.history/project_svm_suara/features_20260524003158.py b/.history/project_svm_suara/features_20260524003158.py new file mode 100644 index 0000000..a9c990d --- /dev/null +++ b/.history/project_svm_suara/features_20260524003158.py @@ -0,0 +1,267 @@ +from collections import Counter +from pathlib import Path +import warnings + +import librosa +import numpy as np + + +SAMPLE_RATE = 22050 +LABEL_PD = "PD" +LABEL_TPD = "TPD" +VALID_LABELS = {LABEL_PD, LABEL_TPD} +N_MFCC = 13 +MIN_DURATION_SECONDS = 0.30 +MIN_RMS_FOR_USABLE_AUDIO = 0.001 + + +def get_label_from_filename(file_path): + """ + Mengambil label dari nama file atau nama folder. + + Urutan pengecekan penting: + - cek "_tpd" lebih dulu + - baru cek "_pd" + """ + path = Path(file_path) + filename = path.stem.lower() + parent = path.parent.name.lower() + + if "_tpd" in filename or parent in {"tpd", "not_confident", "tidak_percaya_diri"}: + return LABEL_TPD + if "_pd" in filename or parent in {"pd", "confident", "percaya_diri"}: + return LABEL_PD + + return None + + +def validate_label(label, file_path): + if label not in VALID_LABELS: + raise ValueError(f"Label tidak valid pada {Path(file_path).name}: {label}") + + +def load_and_preprocess_audio(file_path, sample_rate=SAMPLE_RATE): + """ + Membaca audio dengan preprocessing konsisten untuk training dan prediksi: + mono, sample rate 22050 Hz, trim silence, dan normalisasi volume. + """ + y, sr = librosa.load(file_path, sr=sample_rate, mono=True) + + if y.size == 0: + raise ValueError(f"Audio kosong: {file_path}") + + y, _ = librosa.effects.trim(y, top_db=30) + + if y.size == 0: + raise ValueError(f"Audio hanya berisi silence: {file_path}") + + duration = librosa.get_duration(y=y, sr=sr) + if duration < MIN_DURATION_SECONDS: + raise ValueError( + f"Audio terlalu pendek: {duration:.2f} detik. Minimal {MIN_DURATION_SECONDS:.2f} detik." + ) + + rms_value = float(np.sqrt(np.mean(y**2))) + if rms_value < MIN_RMS_FOR_USABLE_AUDIO: + raise ValueError( + f"Audio terlalu pelan/silent. RMS={rms_value:.5f}, " + f"minimal {MIN_RMS_FOR_USABLE_AUDIO:.5f}." + ) + + max_amplitude = np.max(np.abs(y)) + if max_amplitude > 0: + y = y / max_amplitude + + return y.astype(np.float32), sr + + +def mean_std(feature_matrix): + """ + Mengubah fitur frame-based menjadi statistik tetap. + Output selalu 1 dimensi dan stabil untuk SVM. + """ + feature_matrix = np.atleast_2d(feature_matrix) + return np.concatenate( + [ + np.mean(feature_matrix, axis=1), + np.std(feature_matrix, axis=1), + ] + ) + + +def extract_pitch_features(y, sr): + """ + Mengambil ringkasan fundamental frequency (pitch) dengan pyin. + Jika pitch tidak terdeteksi, nilai pitch dibuat 0 agar fitur tetap konsisten. + """ + f0, _, _ = librosa.pyin( + y, + fmin=librosa.note_to_hz("C2"), + fmax=librosa.note_to_hz("C7"), + sr=sr, + ) + voiced_f0 = f0[~np.isnan(f0)] + + if voiced_f0.size == 0: + return np.array([0.0, 0.0, 0.0], dtype=np.float32) + + voiced_ratio = voiced_f0.size / f0.size + return np.array( + [ + np.mean(voiced_f0), + np.std(voiced_f0), + voiced_ratio, + ], + dtype=np.float32, + ) + + +def extract_features(file_path, sample_rate=SAMPLE_RATE): + """ + Ekstraksi fitur suara yang sama untuk training dan prediksi: + - MFCC mean dan std + - RMS Energy mean dan std + - Zero Crossing Rate mean dan std + - Spectral Centroid mean dan std + - Spectral Bandwidth mean dan std + - Spectral Rolloff mean dan std + - Pitch/fundamental frequency + - Durasi suara aktif + """ + y, sr = load_and_preprocess_audio(file_path, sample_rate=sample_rate) + + mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=N_MFCC) + rms = librosa.feature.rms(y=y) + zcr = librosa.feature.zero_crossing_rate(y) + spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr) + spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr) + spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr) + pitch_features = extract_pitch_features(y, sr) + active_duration = np.array([librosa.get_duration(y=y, sr=sr)], dtype=np.float32) + + feature_vector = np.concatenate( + [ + mean_std(mfcc), + mean_std(rms), + mean_std(zcr), + mean_std(spectral_centroid), + mean_std(spectral_bandwidth), + mean_std(spectral_rolloff), + pitch_features, + active_duration, + ] + ) + + if feature_vector.ndim != 1: + raise ValueError("Fitur audio harus 1 dimensi.") + if not np.all(np.isfinite(feature_vector)): + raise ValueError("Fitur audio mengandung NaN atau infinity.") + + return feature_vector.astype(np.float32) + + +def load_dataset(data_dir): + """ + Membaca semua file .wav pada folder data. + File tanpa label valid atau file rusak dilewati dengan peringatan. + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + + if not audio_files: + raise FileNotFoundError(f"Tidak ada file .wav di folder: {data_path}") + + features = [] + labels = [] + used_files = [] + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + warnings.warn( + f"File dilewati karena nama/folder tidak mengandung label PD atau TPD: " + f"{audio_file.name}" + ) + continue + + try: + validate_label(label, audio_file) + features.append(extract_features(audio_file)) + labels.append(label) + used_files.append(audio_file) + except Exception as error: + warnings.warn(f"File dilewati karena gagal diproses: {audio_file.name} ({error})") + + if not features: + raise ValueError("Tidak ada file audio valid yang berhasil diproses.") + + label_counts = Counter(labels) + print("\n=== Distribusi Label Dataset ===") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + invalid_labels = set(labels) - VALID_LABELS + if invalid_labels: + raise ValueError(f"Ditemukan label tidak valid: {sorted(invalid_labels)}") + + return np.array(features), np.array(labels), used_files + + +def check_dataset_quality(data_dir): + """ + Mengecek kualitas dataset: + - jumlah data PD dan TPD + - durasi setiap audio + - audio terlalu pendek + - audio terlalu pelan/silent + - file rusak + - rekomendasi file yang perlu direkam ulang + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + label_counts = Counter() + problems = [] + + print("\n=== Cek Kualitas Dataset ===") + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + problems.append((audio_file.name, "Label tidak ditemukan")) + continue + + label_counts[label] += 1 + + try: + y_raw, sr = librosa.load(audio_file, sr=SAMPLE_RATE, mono=True) + duration_raw = librosa.get_duration(y=y_raw, sr=sr) + rms_raw = float(np.sqrt(np.mean(y_raw**2))) if y_raw.size else 0.0 + + issue_notes = [] + if duration_raw < MIN_DURATION_SECONDS: + issue_notes.append(f"terlalu pendek ({duration_raw:.2f} detik)") + if rms_raw < MIN_RMS_FOR_USABLE_AUDIO: + issue_notes.append(f"terlalu pelan/silent (RMS={rms_raw:.5f})") + + print( + f"{audio_file.name} | label={label} | durasi={duration_raw:.2f}s | " + f"rms={rms_raw:.5f}" + ) + + if issue_notes: + problems.append((audio_file.name, ", ".join(issue_notes))) + except Exception as error: + problems.append((audio_file.name, f"file rusak/gagal dibaca ({error})")) + + print("\nJumlah data:") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + print("\nRekomendasi rekam ulang/perbaikan:") + if not problems: + print("Tidak ada masalah kualitas audio yang jelas.") + else: + for filename, reason in problems: + print(f"- {filename}: {reason}") + + return problems diff --git a/.history/project_svm_suara/features_20260524031911.py b/.history/project_svm_suara/features_20260524031911.py new file mode 100644 index 0000000..a9c990d --- /dev/null +++ b/.history/project_svm_suara/features_20260524031911.py @@ -0,0 +1,267 @@ +from collections import Counter +from pathlib import Path +import warnings + +import librosa +import numpy as np + + +SAMPLE_RATE = 22050 +LABEL_PD = "PD" +LABEL_TPD = "TPD" +VALID_LABELS = {LABEL_PD, LABEL_TPD} +N_MFCC = 13 +MIN_DURATION_SECONDS = 0.30 +MIN_RMS_FOR_USABLE_AUDIO = 0.001 + + +def get_label_from_filename(file_path): + """ + Mengambil label dari nama file atau nama folder. + + Urutan pengecekan penting: + - cek "_tpd" lebih dulu + - baru cek "_pd" + """ + path = Path(file_path) + filename = path.stem.lower() + parent = path.parent.name.lower() + + if "_tpd" in filename or parent in {"tpd", "not_confident", "tidak_percaya_diri"}: + return LABEL_TPD + if "_pd" in filename or parent in {"pd", "confident", "percaya_diri"}: + return LABEL_PD + + return None + + +def validate_label(label, file_path): + if label not in VALID_LABELS: + raise ValueError(f"Label tidak valid pada {Path(file_path).name}: {label}") + + +def load_and_preprocess_audio(file_path, sample_rate=SAMPLE_RATE): + """ + Membaca audio dengan preprocessing konsisten untuk training dan prediksi: + mono, sample rate 22050 Hz, trim silence, dan normalisasi volume. + """ + y, sr = librosa.load(file_path, sr=sample_rate, mono=True) + + if y.size == 0: + raise ValueError(f"Audio kosong: {file_path}") + + y, _ = librosa.effects.trim(y, top_db=30) + + if y.size == 0: + raise ValueError(f"Audio hanya berisi silence: {file_path}") + + duration = librosa.get_duration(y=y, sr=sr) + if duration < MIN_DURATION_SECONDS: + raise ValueError( + f"Audio terlalu pendek: {duration:.2f} detik. Minimal {MIN_DURATION_SECONDS:.2f} detik." + ) + + rms_value = float(np.sqrt(np.mean(y**2))) + if rms_value < MIN_RMS_FOR_USABLE_AUDIO: + raise ValueError( + f"Audio terlalu pelan/silent. RMS={rms_value:.5f}, " + f"minimal {MIN_RMS_FOR_USABLE_AUDIO:.5f}." + ) + + max_amplitude = np.max(np.abs(y)) + if max_amplitude > 0: + y = y / max_amplitude + + return y.astype(np.float32), sr + + +def mean_std(feature_matrix): + """ + Mengubah fitur frame-based menjadi statistik tetap. + Output selalu 1 dimensi dan stabil untuk SVM. + """ + feature_matrix = np.atleast_2d(feature_matrix) + return np.concatenate( + [ + np.mean(feature_matrix, axis=1), + np.std(feature_matrix, axis=1), + ] + ) + + +def extract_pitch_features(y, sr): + """ + Mengambil ringkasan fundamental frequency (pitch) dengan pyin. + Jika pitch tidak terdeteksi, nilai pitch dibuat 0 agar fitur tetap konsisten. + """ + f0, _, _ = librosa.pyin( + y, + fmin=librosa.note_to_hz("C2"), + fmax=librosa.note_to_hz("C7"), + sr=sr, + ) + voiced_f0 = f0[~np.isnan(f0)] + + if voiced_f0.size == 0: + return np.array([0.0, 0.0, 0.0], dtype=np.float32) + + voiced_ratio = voiced_f0.size / f0.size + return np.array( + [ + np.mean(voiced_f0), + np.std(voiced_f0), + voiced_ratio, + ], + dtype=np.float32, + ) + + +def extract_features(file_path, sample_rate=SAMPLE_RATE): + """ + Ekstraksi fitur suara yang sama untuk training dan prediksi: + - MFCC mean dan std + - RMS Energy mean dan std + - Zero Crossing Rate mean dan std + - Spectral Centroid mean dan std + - Spectral Bandwidth mean dan std + - Spectral Rolloff mean dan std + - Pitch/fundamental frequency + - Durasi suara aktif + """ + y, sr = load_and_preprocess_audio(file_path, sample_rate=sample_rate) + + mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=N_MFCC) + rms = librosa.feature.rms(y=y) + zcr = librosa.feature.zero_crossing_rate(y) + spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr) + spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr) + spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr) + pitch_features = extract_pitch_features(y, sr) + active_duration = np.array([librosa.get_duration(y=y, sr=sr)], dtype=np.float32) + + feature_vector = np.concatenate( + [ + mean_std(mfcc), + mean_std(rms), + mean_std(zcr), + mean_std(spectral_centroid), + mean_std(spectral_bandwidth), + mean_std(spectral_rolloff), + pitch_features, + active_duration, + ] + ) + + if feature_vector.ndim != 1: + raise ValueError("Fitur audio harus 1 dimensi.") + if not np.all(np.isfinite(feature_vector)): + raise ValueError("Fitur audio mengandung NaN atau infinity.") + + return feature_vector.astype(np.float32) + + +def load_dataset(data_dir): + """ + Membaca semua file .wav pada folder data. + File tanpa label valid atau file rusak dilewati dengan peringatan. + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + + if not audio_files: + raise FileNotFoundError(f"Tidak ada file .wav di folder: {data_path}") + + features = [] + labels = [] + used_files = [] + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + warnings.warn( + f"File dilewati karena nama/folder tidak mengandung label PD atau TPD: " + f"{audio_file.name}" + ) + continue + + try: + validate_label(label, audio_file) + features.append(extract_features(audio_file)) + labels.append(label) + used_files.append(audio_file) + except Exception as error: + warnings.warn(f"File dilewati karena gagal diproses: {audio_file.name} ({error})") + + if not features: + raise ValueError("Tidak ada file audio valid yang berhasil diproses.") + + label_counts = Counter(labels) + print("\n=== Distribusi Label Dataset ===") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + invalid_labels = set(labels) - VALID_LABELS + if invalid_labels: + raise ValueError(f"Ditemukan label tidak valid: {sorted(invalid_labels)}") + + return np.array(features), np.array(labels), used_files + + +def check_dataset_quality(data_dir): + """ + Mengecek kualitas dataset: + - jumlah data PD dan TPD + - durasi setiap audio + - audio terlalu pendek + - audio terlalu pelan/silent + - file rusak + - rekomendasi file yang perlu direkam ulang + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + label_counts = Counter() + problems = [] + + print("\n=== Cek Kualitas Dataset ===") + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + problems.append((audio_file.name, "Label tidak ditemukan")) + continue + + label_counts[label] += 1 + + try: + y_raw, sr = librosa.load(audio_file, sr=SAMPLE_RATE, mono=True) + duration_raw = librosa.get_duration(y=y_raw, sr=sr) + rms_raw = float(np.sqrt(np.mean(y_raw**2))) if y_raw.size else 0.0 + + issue_notes = [] + if duration_raw < MIN_DURATION_SECONDS: + issue_notes.append(f"terlalu pendek ({duration_raw:.2f} detik)") + if rms_raw < MIN_RMS_FOR_USABLE_AUDIO: + issue_notes.append(f"terlalu pelan/silent (RMS={rms_raw:.5f})") + + print( + f"{audio_file.name} | label={label} | durasi={duration_raw:.2f}s | " + f"rms={rms_raw:.5f}" + ) + + if issue_notes: + problems.append((audio_file.name, ", ".join(issue_notes))) + except Exception as error: + problems.append((audio_file.name, f"file rusak/gagal dibaca ({error})")) + + print("\nJumlah data:") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + print("\nRekomendasi rekam ulang/perbaikan:") + if not problems: + print("Tidak ada masalah kualitas audio yang jelas.") + else: + for filename, reason in problems: + print(f"- {filename}: {reason}") + + return problems diff --git a/README.md b/README.md new file mode 100644 index 0000000..80b9409 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# ConfiVoice + +Struktur utama project: + +```text +confivoice3/ +├── cv_app/ # Aplikasi Flutter mobile +├── ml/ # Machine learning, model, dataset, dan API prediksi +└── cv_web/ # Website admin untuk melihat data hasil prediksi siswa/i +``` + +## 1. Jalankan API Prediksi + +API berada di folder `ml/`. + +```bash +cd ml +../cv_app/.venv/bin/python -m uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Endpoint penting: + +```text +POST http://localhost:8000/predict +POST http://localhost:8000/predictions +GET http://localhost:8000/predictions +GET http://localhost:8000/model +``` + +`POST /predict` hanya menjalankan analisis dan menampilkan hasil di Flutter. +Data baru masuk database setelah pengguna menekan tombol `Simpan Hasil`, yang +mengirim data ke `POST /predictions`. + +## 2. Jalankan Flutter Mobile + +Flutter berada di folder `cv_app/mobile_app/`. + +```bash +cd cv_app/mobile_app +flutter pub get +flutter run +``` + +Endpoint default emulator Android: + +```text +http://10.0.2.2:8000/predict +``` + +Jika memakai HP fisik, ganti endpoint di menu `Model` menjadi IP komputer, +misalnya: + +```text +http://192.168.1.6:8000/predict +``` + +## 3. Jalankan Website Admin + +Admin berada di folder `cv_web/`. + +```bash +cd cv_web +../cv_app/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8001 +``` + +Buka: + +```text +http://localhost:8001/admin +``` + +## Database phpMyAdmin / MySQL + +Struktur baru default memakai MySQL/MariaDB agar data bisa dicek lewat +phpMyAdmin. + +1. Nyalakan MySQL dari XAMPP/MAMP. +2. Buka phpMyAdmin. +3. Jalankan API. Database dan tabel akan dibuat otomatis jika user MySQL punya + izin `CREATE DATABASE`. + +Konfigurasi default: + +```text +host: 127.0.0.1 +port: 3306 +user: root +password: kosong +database: confivoice +table: prediction_results +``` + +Jika setting MySQL kamu berbeda, pakai environment: + +```bash +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice +``` + +Untuk MAMP, biasanya port `8889` dan password `root`. + +Schema SQL manual tersedia di: + +```text +cv_web/schema_mysql.sql +``` + +Jika ada data SQLite lama yang mau dipindah ke MySQL: + +```bash +cd ml +../cv_app/.venv/bin/python migrate_sqlite_to_mysql.py +``` + +SQLite lama, jika masih ada, berada di: + +```text +cv_web/data/confivoice.db +``` + +## File Lama + +Beberapa file lama di `cv_app/` masih dipertahankan agar command lama tidak +langsung rusak: + +```text +cv_app/api_app.py +cv_app/app.py +cv_app/predict_app.py +cv_app/audio_utils_app.py +cv_app/database.py +``` + +Untuk struktur baru, gunakan: + +```text +ml/api_app.py +cv_web/app.py +cv_app/mobile_app/ +``` diff --git a/cv_app/README.md b/cv_app/README.md new file mode 100644 index 0000000..91791bc --- /dev/null +++ b/cv_app/README.md @@ -0,0 +1,122 @@ +# ConfiVoice Mobile App + +Folder `cv_app/` dipakai untuk aplikasi Flutter mobile. + +Kode Flutter berada di: + +```text +cv_app/mobile_app/ +``` + +File lama Streamlit/API masih ada di folder ini untuk kompatibilitas, tetapi +struktur baru yang disarankan adalah: + +```text +ml/ # API prediksi dan model machine learning +cv_web/ # Website admin +cv_app/ # Flutter mobile +``` + +## Menjalankan Flutter + +```bash +cd cv_app/mobile_app +flutter pub get +flutter run +``` + +## Endpoint API + +Jalankan API dari folder `ml/`: + +```bash +cd ml +uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Endpoint prediksi: + +```text +POST /predict +``` + +Field multipart upload: + +```text +file +student_name +``` + +Setiap prediksi dari Flutter akan disimpan ke database default: + +```text +cv_web/data/confivoice.db +``` + +SQLite tetap database SQL, tetapi tidak perlu server database terpisah. Cocok +untuk menjalankan aplikasi lokal/TA. + +Jika ingin melihat database lewat phpMyAdmin, gunakan mode MySQL/MariaDB: + +1. Nyalakan MySQL dari XAMPP/MAMP/Laragon. +2. Buka phpMyAdmin. +3. Buat database bernama `confivoice`, atau biarkan API membuat database otomatis jika user MySQL punya izin `CREATE DATABASE`. +4. Install dependency: + +```bash +cd cv_app +source .venv/bin/activate +pip install PyMySQL +``` + +5. Jalankan API dengan konfigurasi MySQL: + +```bash +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice +python -m uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Untuk MAMP, password root biasanya `root` dan port MySQL bisa `8889`: + +```bash +export CONFIVOICE_MYSQL_PASSWORD=root +export CONFIVOICE_MYSQL_PORT=8889 +``` + +Setelah prediksi dari Flutter, data akan masuk ke tabel: + +```text +confivoice.prediction_results +``` + +Endpoint untuk melihat data: + +```text +GET /predictions +GET /admin +``` + +Website admin dapat dibuka di: + +```text +http://localhost:8001/admin +``` + +Jika memakai HP fisik, ganti `localhost` dengan IP komputer yang menjalankan API, +misalnya: + +```text +http://192.168.1.6:8000/admin +``` + +## Catatan + +- Folder `cv_app/` tidak berisi dataset utama. +- Folder `cv_app/` tidak berisi model utama. +- File upload diproses sebagai file sementara menggunakan `tempfile`. +- Audio non-WAV dikonversi otomatis ke WAV mono 22050 Hz menggunakan `pydub` dan `ffmpeg`. diff --git a/cv_app/api_app.py b/cv_app/api_app.py new file mode 100644 index 0000000..4a49eaa --- /dev/null +++ b/cv_app/api_app.py @@ -0,0 +1,25 @@ +""" +Compatibility wrapper. + +API utama berada di ../ml/api_app.py. File ini dipertahankan agar command lama +`cd cv_app && uvicorn api_app:app` tetap memakai API terbaru. +""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys + + +ML_DIR = Path(__file__).resolve().parent.parent / "ml" +ML_API_PATH = ML_DIR / "api_app.py" + +if str(ML_DIR) not in sys.path: + sys.path.insert(0, str(ML_DIR)) + +spec = spec_from_file_location("confivoice_ml_api_app", ML_API_PATH) +if spec is None or spec.loader is None: + raise RuntimeError(f"Gagal memuat API utama: {ML_API_PATH}") + +module = module_from_spec(spec) +spec.loader.exec_module(module) +app = module.app diff --git a/cv_app/app.py b/cv_app/app.py new file mode 100644 index 0000000..2ca76df --- /dev/null +++ b/cv_app/app.py @@ -0,0 +1,267 @@ +from pathlib import Path + +import librosa +import numpy as np +import pandas as pd +import streamlit as st + +from audio_utils_app import ( + SUPPORTED_AUDIO_EXTENSIONS, + cleanup_temp_files, + convert_audio_to_wav, + save_uploaded_file_to_temp, +) +from predict_app import ( + CONFIDENCE_THRESHOLD, + MODEL_NOT_FOUND_MESSAGE, + MODEL_PATH, + get_model_info, + predict_audio, +) + + +st.set_page_config( + page_title="ConfiVoice", + page_icon="CV", + layout="wide", +) + + +SUPPORTED_UPLOAD_TYPES = [ + extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS) +] + + +def load_audio_for_plot(wav_path): + y, sr = librosa.load(wav_path, sr=22050, mono=True) + if y.size == 0: + raise ValueError("Audio kosong atau tidak memiliki sinyal suara.") + return y, sr + + +def render_waveform(wav_path): + y, sr = load_audio_for_plot(wav_path) + max_points = 3000 + step = max(1, len(y) // max_points) + y_plot = y[::step] + time_axis = np.arange(len(y_plot)) * step / sr + + waveform = pd.DataFrame( + { + "Waktu (detik)": time_axis, + "Amplitudo": y_plot, + } + ).set_index("Waktu (detik)") + + st.subheader("Waveform") + st.line_chart(waveform, height=260) + + +def render_spectrogram(wav_path): + y, sr = load_audio_for_plot(wav_path) + spectrogram = librosa.amplitude_to_db(np.abs(librosa.stft(y)), ref=np.max) + min_value = float(np.min(spectrogram)) + max_value = float(np.max(spectrogram)) + + if max_value > min_value: + spectrogram_image = (spectrogram - min_value) / (max_value - min_value) + else: + spectrogram_image = np.zeros_like(spectrogram) + + st.subheader("Spectrogram") + st.image( + np.flipud(spectrogram_image), + caption="Frekuensi rendah di bawah, frekuensi tinggi di atas.", + use_container_width=True, + clamp=True, + ) + + +def render_audio_uploader(key): + return st.file_uploader( + "Upload audio", + type=SUPPORTED_UPLOAD_TYPES, + key=key, + ) + + +def prepare_uploaded_audio(uploaded_file): + temp_input_path = save_uploaded_file_to_temp(uploaded_file) + temp_wav_path = convert_audio_to_wav(temp_input_path) + return temp_input_path, temp_wav_path + + +def render_prediction_result(result): + if result.get("is_valid_audio") is False: + st.subheader("Audio Tidak Valid") + st.error(result.get("error_message") or result.get("explanation") or "Audio tidak valid.") + with st.expander("Kualitas audio"): + st.json(result.get("audio_quality", {})) + return + + probability_pd = result["probabilities"]["PD"] + probability_tpd = result["probabilities"]["TPD"] + confidence = result["confidence"] + + st.subheader("Hasil Prediksi") + metric_cols = st.columns(3) + metric_cols[0].metric("Label", result["label"]) + metric_cols[1].metric("Keterangan", result["description"]) + metric_cols[2].metric("Confidence", f"{confidence * 100:.2f}%") + + st.progress(probability_pd, text=f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.progress(probability_tpd, text=f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + + if confidence < CONFIDENCE_THRESHOLD: + st.warning("Model belum yakin. Confidence di bawah 60%, pertimbangkan untuk merekam ulang audio.") + else: + st.success(f"Hasil utama: {result['label']} - {result['description']}") + + if result.get("explanation"): + st.info(result["explanation"]) + + with st.expander("Kualitas audio dan indikator suara"): + st.write("Kualitas audio") + st.json(result.get("audio_quality", {})) + st.write("Indikator suara") + st.json(result.get("voice_indicators", {})) + + with st.expander("Output dictionary"): + st.json(result) + + +def render_upload_preview(uploaded_file, show_visuals=True): + temp_input_path = None + temp_wav_path = None + + try: + temp_input_path, temp_wav_path = prepare_uploaded_audio(uploaded_file) + st.audio(uploaded_file.getvalue(), format=uploaded_file.type or "audio/wav") + + if show_visuals: + waveform_col, spectrogram_col = st.columns(2) + with waveform_col: + render_waveform(temp_wav_path) + with spectrogram_col: + render_spectrogram(temp_wav_path) + + return temp_input_path + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + return None + finally: + cleanup_temp_files(temp_wav_path) + + +def show_home(): + st.title("ConfiVoice") + st.write("Aplikasi Streamlit untuk memprediksi suara PD atau TPD dari model machine learning.") + + st.info( + "Folder aplikasi ini hanya membaca model dari `../ml/models/` dan fitur dari `../ml/features.py`." + ) + + st.write("Menu yang tersedia:") + st.write("- Beranda") + st.write("- Prediksi Suara") + st.write("- Visualisasi Audio") + st.write("- Informasi Model") + st.write("- Tentang Sistem") + + +def show_prediction(): + st.title("Prediksi Suara") + st.write("Upload audio dalam format WAV, MP3, M4A, OGG, FLAC, WEBM, atau AAC.") + + uploaded_file = render_audio_uploader("prediction_upload") + if uploaded_file is None: + return + + temp_input_path = None + temp_wav_path = None + + try: + temp_input_path, temp_wav_path = prepare_uploaded_audio(uploaded_file) + st.audio(uploaded_file.getvalue(), format=uploaded_file.type or "audio/wav") + + waveform_col, spectrogram_col = st.columns(2) + with waveform_col: + render_waveform(temp_wav_path) + with spectrogram_col: + render_spectrogram(temp_wav_path) + + if st.button("Prediksi", type="primary"): + with st.spinner("Mengekstraksi fitur dan menjalankan model..."): + result = predict_audio(temp_input_path) + render_prediction_result(result) + except FileNotFoundError as error: + message = MODEL_NOT_FOUND_MESSAGE if str(error) == MODEL_NOT_FOUND_MESSAGE else str(error) + st.error(message) + except Exception as error: + st.error(f"Gagal memproses prediksi: {error}") + finally: + cleanup_temp_files(temp_input_path, temp_wav_path) + + +def show_visualization(): + st.title("Visualisasi Audio") + uploaded_file = render_audio_uploader("visualization_upload") + if uploaded_file is None: + return + + temp_input_path = None + temp_wav_path = None + + try: + temp_input_path, temp_wav_path = prepare_uploaded_audio(uploaded_file) + st.audio(uploaded_file.getvalue(), format=uploaded_file.type or "audio/wav") + render_waveform(temp_wav_path) + render_spectrogram(temp_wav_path) + except Exception as error: + st.error(f"Gagal membuat visualisasi: {error}") + finally: + cleanup_temp_files(temp_input_path, temp_wav_path) + + +def show_model_info(): + st.title("Informasi Model") + + try: + info = get_model_info() + except FileNotFoundError: + st.error(MODEL_NOT_FOUND_MESSAGE) + return + except Exception as error: + st.error(f"Gagal membaca informasi model: {error}") + return + + st.write(f"Path model: `{info['model_path']}`") + st.write(f"Tipe model: `{info['model_type']}`") + st.write(f"Class model: `{', '.join(info['classes'])}`") + st.write(f"Jumlah fitur yang diharapkan: `{info['expected_features']}`") + st.write(f"Model utama: `{MODEL_PATH.relative_to(MODEL_PATH.parents[2])}`") + + +def show_about(): + st.title("Tentang Sistem") + st.write( + "ConfiVoice memisahkan aplikasi Streamlit dari proses machine learning. " + "Folder `cv_app/` berisi antarmuka aplikasi, sementara folder `ml/` tetap menjadi tempat dataset, training, fitur audio, dan model." + ) + st.write( + "Audio upload dikonversi sementara ke WAV mono 22050 Hz, lalu fitur diekstraksi memakai `../ml/features.py`. " + "Tidak ada audio upload yang disimpan permanen ke `ml/data/`." + ) + + +MENU_HANDLERS = { + "Beranda": show_home, + "Prediksi Suara": show_prediction, + "Visualisasi Audio": show_visualization, + "Informasi Model": show_model_info, + "Tentang Sistem": show_about, +} + + +selected_menu = st.sidebar.radio("Menu", list(MENU_HANDLERS.keys())) +MENU_HANDLERS[selected_menu]() diff --git a/cv_app/audio_utils_app.py b/cv_app/audio_utils_app.py new file mode 100644 index 0000000..987d66c --- /dev/null +++ b/cv_app/audio_utils_app.py @@ -0,0 +1,86 @@ +from pathlib import Path +import tempfile +import warnings + +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="Couldn't find ffmpeg or avconv.*", + category=RuntimeWarning, + ) + from pydub import AudioSegment + +try: + import imageio_ffmpeg +except ImportError: + imageio_ffmpeg = None + + +TARGET_SAMPLE_RATE = 22050 +SUPPORTED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac"} + +if imageio_ffmpeg is not None: + AudioSegment.converter = imageio_ffmpeg.get_ffmpeg_exe() + + +def validate_audio_extension(file_path): + extension = Path(file_path).suffix.lower() + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)).upper().replace(".", "") + raise ValueError(f"Format audio tidak didukung: {extension}. Format yang didukung: {allowed}") + + +def save_uploaded_file_to_temp(uploaded_file): + """ + Menyimpan file upload Streamlit ke file sementara. + File ini bukan dataset dan tidak disimpan ke ml/data. + """ + suffix = Path(uploaded_file.name).suffix.lower() or ".wav" + validate_audio_extension(f"audio{suffix}") + + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + try: + temp_file.write(uploaded_file.getvalue()) + return Path(temp_file.name) + finally: + temp_file.close() + + +def create_temp_wav_path(): + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") + temp_path = Path(temp_file.name) + temp_file.close() + return temp_path + + +def convert_audio_to_wav(input_path, output_path=None): + """ + Konversi audio upload ke WAV mono 22050 Hz menggunakan pydub/ffmpeg. + """ + input_path = Path(input_path) + validate_audio_extension(input_path) + + if not input_path.exists(): + raise FileNotFoundError(f"File audio tidak ditemukan: {input_path}") + + output_path = Path(output_path) if output_path else create_temp_wav_path() + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + audio = AudioSegment.from_file(input_path) + except FileNotFoundError as error: + raise RuntimeError( + "ffmpeg tidak ditemukan. Pastikan ffmpeg sudah terpasang dan dapat diakses oleh pydub." + ) from error + except Exception as error: + raise RuntimeError(f"Gagal membaca atau mengonversi audio: {error}") from error + + audio = audio.set_channels(1).set_frame_rate(TARGET_SAMPLE_RATE) + audio.export(output_path, format="wav") + return output_path + + +def cleanup_temp_files(*paths): + for path in paths: + if path: + Path(path).unlink(missing_ok=True) diff --git a/cv_app/database.py b/cv_app/database.py new file mode 100644 index 0000000..1b0ab87 --- /dev/null +++ b/cv_app/database.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +DB_PATH = DATA_DIR / "confivoice.db" + +DB_DRIVER = os.getenv("CONFIVOICE_DB_DRIVER", "sqlite").lower() +MYSQL_HOST = os.getenv("CONFIVOICE_MYSQL_HOST", "127.0.0.1") +MYSQL_PORT = int(os.getenv("CONFIVOICE_MYSQL_PORT", "3306")) +MYSQL_USER = os.getenv("CONFIVOICE_MYSQL_USER", "root") +MYSQL_PASSWORD = os.getenv("CONFIVOICE_MYSQL_PASSWORD", "") +MYSQL_DATABASE = os.getenv("CONFIVOICE_MYSQL_DATABASE", "confivoice") + + +def get_database_label(): + if DB_DRIVER == "mysql": + return f"mysql://{MYSQL_USER}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}" + return str(DB_PATH) + + +def get_connection(): + if DB_DRIVER == "mysql": + return get_mysql_connection() + + DATA_DIR.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(DB_PATH) + connection.row_factory = sqlite3.Row + return connection + + +def get_mysql_connection(database=MYSQL_DATABASE): + try: + import pymysql + except ImportError as error: + raise RuntimeError( + "PyMySQL belum terinstall. Jalankan: pip install PyMySQL" + ) from error + + return pymysql.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + database=database, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + + +def init_mysql_db(): + with get_mysql_connection(database=None) as connection: + with connection.cursor() as cursor: + cursor.execute( + f"CREATE DATABASE IF NOT EXISTS `{MYSQL_DATABASE}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + connection.commit() + + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS prediction_results ( + id INT AUTO_INCREMENT PRIMARY KEY, + student_name VARCHAR(255) NOT NULL, + predicted_label VARCHAR(20), + description VARCHAR(255), + confidence DOUBLE NOT NULL DEFAULT 0, + probability_pd DOUBLE NOT NULL DEFAULT 0, + probability_tpd DOUBLE NOT NULL DEFAULT 0, + is_valid_audio TINYINT(1) NOT NULL DEFAULT 1, + error_message TEXT, + audio_duration DOUBLE NOT NULL DEFAULT 0, + volume_score DOUBLE NOT NULL DEFAULT 0, + intonation_score DOUBLE NOT NULL DEFAULT 0, + pause_score DOUBLE NOT NULL DEFAULT 0, + speech_activity_ratio DOUBLE NOT NULL DEFAULT 0, + silence_ratio DOUBLE NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.commit() + + +def init_db(): + if DB_DRIVER == "mysql": + init_mysql_db() + return + + with get_connection() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS prediction_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_name TEXT NOT NULL, + predicted_label TEXT, + description TEXT, + confidence REAL NOT NULL DEFAULT 0, + probability_pd REAL NOT NULL DEFAULT 0, + probability_tpd REAL NOT NULL DEFAULT 0, + is_valid_audio INTEGER NOT NULL DEFAULT 1, + error_message TEXT, + audio_duration REAL NOT NULL DEFAULT 0, + volume_score REAL NOT NULL DEFAULT 0, + intonation_score REAL NOT NULL DEFAULT 0, + pause_score REAL NOT NULL DEFAULT 0, + speech_activity_ratio REAL NOT NULL DEFAULT 0, + silence_ratio REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.commit() + + +def save_prediction_result(student_name, result): + init_db() + audio_quality = result.get("audio_quality") or {} + indicators = result.get("voice_indicators") or {} + + if DB_DRIVER == "mysql": + return save_prediction_result_mysql(student_name, result, audio_quality, indicators) + + with get_connection() as connection: + cursor = connection.execute( + """ + INSERT INTO prediction_results ( + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + student_name, + result.get("predicted_label") or result.get("label"), + result.get("description"), + float(result.get("confidence") or 0), + float(result.get("probability_pd") or 0), + float(result.get("probability_tpd") or 0), + 1 if result.get("is_valid_audio") is not False else 0, + result.get("error_message"), + float(audio_quality.get("duration") or 0), + float(indicators.get("volume_score") or 0), + float(indicators.get("intonation_score") or 0), + float(indicators.get("pause_score") or 0), + float(indicators.get("speech_activity_ratio") or 0), + float(indicators.get("silence_ratio") or 0), + ), + ) + connection.commit() + return cursor.lastrowid + + +def save_prediction_result_mysql(student_name, result, audio_quality, indicators): + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO prediction_results ( + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + student_name, + result.get("predicted_label") or result.get("label"), + result.get("description"), + float(result.get("confidence") or 0), + float(result.get("probability_pd") or 0), + float(result.get("probability_tpd") or 0), + 1 if result.get("is_valid_audio") is not False else 0, + result.get("error_message"), + float(audio_quality.get("duration") or 0), + float(indicators.get("volume_score") or 0), + float(indicators.get("intonation_score") or 0), + float(indicators.get("pause_score") or 0), + float(indicators.get("speech_activity_ratio") or 0), + float(indicators.get("silence_ratio") or 0), + ), + ) + prediction_id = cursor.lastrowid + connection.commit() + return prediction_id + + +def list_prediction_results(limit=200): + init_db() + if DB_DRIVER == "mysql": + return list_prediction_results_mysql(limit=limit) + + with get_connection() as connection: + rows = connection.execute( + """ + SELECT + id, + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio, + created_at + FROM prediction_results + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + return [dict(row) for row in rows] + + +def list_prediction_results_mysql(limit=200): + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT + id, + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio, + created_at + FROM prediction_results + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (limit,), + ) + rows = cursor.fetchall() + + for row in rows: + row["created_at"] = str(row["created_at"]) + return rows diff --git a/cv_app/mobile_app/.gitignore b/cv_app/mobile_app/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/cv_app/mobile_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/cv_app/mobile_app/.metadata b/cv_app/mobile_app/.metadata new file mode 100644 index 0000000..9fc6a8a --- /dev/null +++ b/cv_app/mobile_app/.metadata @@ -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: "ea121f8859e4b13e47a8f845e4586164519588bc" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + - platform: web + create_revision: ea121f8859e4b13e47a8f845e4586164519588bc + base_revision: ea121f8859e4b13e47a8f845e4586164519588bc + + # 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' diff --git a/cv_app/mobile_app/README.md b/cv_app/mobile_app/README.md new file mode 100644 index 0000000..d1b9bd6 --- /dev/null +++ b/cv_app/mobile_app/README.md @@ -0,0 +1,88 @@ +# ConfiVoice Mobile + +Flutter Android client untuk aplikasi ConfiVoice. + +Mobile app ini tidak menyimpan dataset atau model. Audio dipilih dari perangkat Android, lalu dikirim ke API prediksi. API tetap bertugas membaca model dari: + +```text +../ml/models/svm_voice_confidence_model.joblib +``` + +Audio bisa berasal dari file yang dipilih dari perangkat atau dari fitur rekam suara langsung di halaman `Prediksi`. Rekaman disimpan sementara sebagai WAV mono 22050 Hz di storage aplikasi. + +## Menjalankan di Emulator + +Jalankan API dari folder `cv_app`: + +```bash +cd ml +uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Lalu jalankan mobile app: + +```bash +cd cv_app/mobile_app +flutter pub get +flutter run +``` + +Default endpoint di aplikasi: + +```text +http://10.0.2.2:8000/predict +``` + +`10.0.2.2` adalah alamat khusus emulator Android untuk mengakses `localhost` komputer. Alamat ini tidak bisa dipakai dari HP fisik. + +Saat prediksi, isi `Nama siswa/i` di halaman `Prediksi`. Nama dan hasil analisis +akan disimpan oleh API ke database SQLite: + +```text +cv_web/data/confivoice.db +``` + +Admin dapat melihat hasil dari browser: + +```text +http://localhost:8001/admin +``` + +## Menjalankan di HP Fisik + +Ganti endpoint pada menu `Model` menjadi alamat IP komputer yang menjalankan API, misalnya: + +```text +http://192.168.1.6:8000/predict +``` + +Pastikan HP dan komputer berada di jaringan yang sama. + +## Build APK + +```bash +cd cv_app/mobile_app +flutter build apk --release +``` + +Output APK biasanya berada di: + +```text +build/app/outputs/flutter-apk/app-release.apk +``` + +## Format Response API + +Aplikasi mengharapkan JSON: + +```json +{ + "label": "PD", + "description": "Percaya Diri", + "confidence": 0.76, + "probabilities": { + "PD": 0.76, + "TPD": 0.24 + } +} +``` diff --git a/cv_app/mobile_app/analysis_options.yaml b/cv_app/mobile_app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/cv_app/mobile_app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/cv_app/mobile_app/android/.gitignore b/cv_app/mobile_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/cv_app/mobile_app/android/.gitignore @@ -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 diff --git a/cv_app/mobile_app/android/app/build.gradle.kts b/cv_app/mobile_app/android/app/build.gradle.kts new file mode 100644 index 0000000..257fd3d --- /dev/null +++ b/cv_app/mobile_app/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.confivoice.mobile" + compileSdk = flutter.compileSdkVersion + ndkVersion = "30.0.14904198" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.confivoice.mobile" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 23 + 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") + } + } +} + +flutter { + source = "../.." +} diff --git a/cv_app/mobile_app/android/app/src/debug/AndroidManifest.xml b/cv_app/mobile_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/cv_app/mobile_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/cv_app/mobile_app/android/app/src/main/AndroidManifest.xml b/cv_app/mobile_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..df64f9e --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/main/kotlin/com/confivoice/mobile/MainActivity.kt b/cv_app/mobile_app/android/app/src/main/kotlin/com/confivoice/mobile/MainActivity.kt new file mode 100644 index 0000000..d605807 --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/kotlin/com/confivoice/mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.confivoice.mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/cv_app/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml b/cv_app/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/main/res/drawable/launch_background.xml b/cv_app/mobile_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..eec190f Binary files /dev/null and b/cv_app/mobile_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..76f0272 Binary files /dev/null and b/cv_app/mobile_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..749eaf0 Binary files /dev/null and b/cv_app/mobile_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..c1e7283 Binary files /dev/null and b/cv_app/mobile_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/cv_app/mobile_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/cv_app/mobile_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..5550a71 Binary files /dev/null and b/cv_app/mobile_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/cv_app/mobile_app/android/app/src/main/res/values-night/styles.xml b/cv_app/mobile_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/main/res/values/styles.xml b/cv_app/mobile_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/cv_app/mobile_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/cv_app/mobile_app/android/app/src/profile/AndroidManifest.xml b/cv_app/mobile_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/cv_app/mobile_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/cv_app/mobile_app/android/build.gradle.kts b/cv_app/mobile_app/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/cv_app/mobile_app/android/build.gradle.kts @@ -0,0 +1,21 @@ +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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/cv_app/mobile_app/android/gradle.properties b/cv_app/mobile_app/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/cv_app/mobile_app/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/cv_app/mobile_app/android/gradle/wrapper/gradle-wrapper.properties b/cv_app/mobile_app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..afa1e8e --- /dev/null +++ b/cv_app/mobile_app/android/gradle/wrapper/gradle-wrapper.properties @@ -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-8.10.2-all.zip diff --git a/cv_app/mobile_app/android/settings.gradle.kts b/cv_app/mobile_app/android/settings.gradle.kts new file mode 100644 index 0000000..a439442 --- /dev/null +++ b/cv_app/mobile_app/android/settings.gradle.kts @@ -0,0 +1,25 @@ +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 "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/cv_app/mobile_app/devtools_options.yaml b/cv_app/mobile_app/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/cv_app/mobile_app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/cv_app/mobile_app/lib/app/app.dart b/cv_app/mobile_app/lib/app/app.dart new file mode 100644 index 0000000..6140936 --- /dev/null +++ b/cv_app/mobile_app/lib/app/app.dart @@ -0,0 +1,143 @@ +part of '../main.dart'; + +class AppColors { + static const background = Color(0xFF05080F); + static const surface = Color(0xFF0B1220); + static const surfaceRaised = Color(0xFF101B30); + static const navy = Color(0xFF102A56); + static const blue = Color(0xFF3B82F6); + static const blueSoft = Color(0xFF93C5FD); + static const border = Color(0xFF203354); + static const text = Color(0xFFF1F5F9); + static const textMuted = Color(0xFF9FB0C9); + static const warningBackground = Color(0xFF2B1E10); + static const warningBorder = Color(0xFF8A5A20); + static const warningText = Color(0xFFFED7AA); + static const recordingBackground = Color(0xFF29151D); + static const recordingBorder = Color(0xFF7F3148); + static const recording = Color(0xFFF87171); +} + +void showCompactSnackBar( + BuildContext context, { + required String message, + IconData? icon, +}) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + duration: const Duration(milliseconds: 1500), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(18, 0, 18, 18), + elevation: 0, + backgroundColor: AppColors.navy, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + content: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 10), + ], + Expanded( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); +} + +class ConfiVoiceMobileApp extends StatelessWidget { + const ConfiVoiceMobileApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'ConfiVoice', + theme: ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: AppColors.blue, + onPrimary: Colors.white, + secondary: AppColors.blueSoft, + surface: AppColors.surface, + onSurface: AppColors.text, + outline: AppColors.border, + error: AppColors.recording, + ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.text, + ), + cardTheme: CardTheme( + elevation: 0, + color: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceRaised, + border: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.blue, width: 1.5), + ), + labelStyle: TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.blueSoft, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.navy, + disabledForegroundColor: AppColors.textMuted, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.blueSoft, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + ), + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.blue, + linearTrackColor: AppColors.navy, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: AppColors.surfaceRaised, + contentTextStyle: TextStyle(color: AppColors.text), + ), + useMaterial3: true, + ), + home: const AuthGate(), + ); + } +} diff --git a/cv_app/mobile_app/lib/audio_source.dart b/cv_app/mobile_app/lib/audio_source.dart new file mode 100644 index 0000000..21edacb --- /dev/null +++ b/cv_app/mobile_app/lib/audio_source.dart @@ -0,0 +1,3 @@ +export 'audio_source_stub.dart' + if (dart.library.io) 'audio_source_io.dart' + if (dart.library.html) 'audio_source_web.dart'; diff --git a/cv_app/mobile_app/lib/audio_source_io.dart b/cv_app/mobile_app/lib/audio_source_io.dart new file mode 100644 index 0000000..caf9903 --- /dev/null +++ b/cv_app/mobile_app/lib/audio_source_io.dart @@ -0,0 +1,74 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:file_picker/file_picker.dart'; +import 'package:http/http.dart' as http; + +class SelectedAudio { + const SelectedAudio({required this.name, required this.path}); + + final String name; + final String path; +} + +Future pickAudio(List supportedExtensions) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: supportedExtensions, + allowMultiple: false, + ); + + final pickedFile = result?.files.single; + final path = pickedFile?.path; + if (pickedFile == null || path == null) { + return null; + } + + return SelectedAudio(name: pickedFile.name, path: path); +} + +Future audioFromRecorderPath( + String path, + String fallbackName, +) async { + return SelectedAudio( + name: path.split(Platform.pathSeparator).last, + path: path, + ); +} + +Future> buildWavePreview(SelectedAudio audio) async { + final bytes = await File(audio.path).readAsBytes(); + return _wavePoints(bytes); +} + +Future multipartFileFromAudio( + String fieldName, + SelectedAudio audio, +) { + return http.MultipartFile.fromPath(fieldName, audio.path); +} + +String recordingFilePath(String fileName) { + return '${Directory.systemTemp.path}${Platform.pathSeparator}$fileName'; +} + +List _wavePoints(List bytes) { + if (bytes.isEmpty) { + return const []; + } + + const pointCount = 96; + final step = max(1, bytes.length ~/ pointCount); + final points = []; + + for (var index = 0; index < bytes.length; index += step) { + final normalized = (bytes[index] - 128) / 128.0; + points.add(normalized.clamp(-1.0, 1.0)); + if (points.length == pointCount) { + break; + } + } + + return points; +} diff --git a/cv_app/mobile_app/lib/audio_source_stub.dart b/cv_app/mobile_app/lib/audio_source_stub.dart new file mode 100644 index 0000000..861a6d8 --- /dev/null +++ b/cv_app/mobile_app/lib/audio_source_stub.dart @@ -0,0 +1,28 @@ +import 'package:http/http.dart' as http; + +class SelectedAudio { + const SelectedAudio({required this.name}); + + final String name; +} + +Future pickAudio(List supportedExtensions) { + throw UnsupportedError('Audio picking is not supported on this platform.'); +} + +Future audioFromRecorderPath(String path, String fallbackName) { + throw UnsupportedError('Recording is not supported on this platform.'); +} + +Future> buildWavePreview(SelectedAudio audio) { + throw UnsupportedError('Waveform preview is not supported on this platform.'); +} + +Future multipartFileFromAudio( + String fieldName, + SelectedAudio audio, +) { + throw UnsupportedError('Audio upload is not supported on this platform.'); +} + +String recordingFilePath(String fileName) => fileName; diff --git a/cv_app/mobile_app/lib/audio_source_web.dart b/cv_app/mobile_app/lib/audio_source_web.dart new file mode 100644 index 0000000..0aac776 --- /dev/null +++ b/cv_app/mobile_app/lib/audio_source_web.dart @@ -0,0 +1,76 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:http/http.dart' as http; + +class SelectedAudio { + const SelectedAudio({required this.name, required this.bytes}); + + final String name; + final Uint8List bytes; +} + +Future pickAudio(List supportedExtensions) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: supportedExtensions, + allowMultiple: false, + withData: true, + ); + + final pickedFile = result?.files.single; + final bytes = pickedFile?.bytes; + if (pickedFile == null || bytes == null) { + return null; + } + + return SelectedAudio(name: pickedFile.name, bytes: bytes); +} + +Future audioFromRecorderPath( + String path, + String fallbackName, +) async { + final response = await http.get(Uri.parse(path)); + if (response.statusCode < 200 || response.statusCode >= 300) { + return null; + } + + return SelectedAudio(name: fallbackName, bytes: response.bodyBytes); +} + +Future> buildWavePreview(SelectedAudio audio) async { + return _wavePoints(audio.bytes); +} + +Future multipartFileFromAudio( + String fieldName, + SelectedAudio audio, +) { + return Future.value( + http.MultipartFile.fromBytes(fieldName, audio.bytes, filename: audio.name), + ); +} + +String recordingFilePath(String fileName) => fileName; + +List _wavePoints(List bytes) { + if (bytes.isEmpty) { + return const []; + } + + const pointCount = 96; + final step = max(1, bytes.length ~/ pointCount); + final points = []; + + for (var index = 0; index < bytes.length; index += step) { + final normalized = (bytes[index] - 128) / 128.0; + points.add(normalized.clamp(-1.0, 1.0)); + if (points.length == pointCount) { + break; + } + } + + return points; +} diff --git a/cv_app/mobile_app/lib/fitur/auth/auth.dart b/cv_app/mobile_app/lib/fitur/auth/auth.dart new file mode 100644 index 0000000..75b7fd3 --- /dev/null +++ b/cv_app/mobile_app/lib/fitur/auth/auth.dart @@ -0,0 +1,707 @@ +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), + ), + ); + } +} diff --git a/cv_app/mobile_app/lib/fitur/home/confivoice_shell.dart b/cv_app/mobile_app/lib/fitur/home/confivoice_shell.dart new file mode 100644 index 0000000..392a3ee --- /dev/null +++ b/cv_app/mobile_app/lib/fitur/home/confivoice_shell.dart @@ -0,0 +1,790 @@ +part of '../../main.dart'; + +class ConfiVoiceShell extends StatefulWidget { + const ConfiVoiceShell({ + super.key, + required this.session, + required this.onLogout, + }); + + final UserSession session; + final Future Function() onLogout; + + @override + State createState() => _ConfiVoiceShellState(); +} + +class _ConfiVoiceShellState extends State { + static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint'; + static const supportedExtensions = [ + 'wav', + 'mp3', + 'm4a', + 'ogg', + 'flac', + 'webm', + 'aac', + ]; + + final TextEditingController _apiController = TextEditingController( + text: defaultApiEndpoint(), + ); + final TextEditingController _studentNameController = TextEditingController(); + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _amplitudeSubscription; + + SelectedAudio? _selectedAudio; + PredictionResult? _prediction; + String? _errorMessage; + bool _isPredicting = false; + bool _isSaving = false; + bool _isRecording = false; + String _studentGender = ''; + List _studentNames = const []; + Map _studentGenders = const {}; + List _liveWavePreview = const []; + List _wavePreview = const []; + + @override + void initState() { + super.initState(); + _initializeShell(); + } + + @override + void dispose() { + _amplitudeSubscription?.cancel(); + _apiController.dispose(); + _studentNameController.dispose(); + _recorder.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 (_) { + // Gunakan endpoint default jika storage lokal belum dapat dibaca. + } + } + + Future _saveApiEndpoint() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + _apiEndpointPreferenceKey, + _apiController.text.trim(), + ); + } + + String get _studentNamesPreferenceKey => 'confivoice_student_names_shared'; + String get _studentGendersPreferenceKey => + 'confivoice_student_genders_shared'; + + Future _initializeShell() async { + await _loadApiEndpoint(); + await _loadStudentNames(); + } + + Future _loadStudentNames() async { + try { + final preferences = await SharedPreferences.getInstance(); + final localNames = + preferences.getStringList(_studentNamesPreferenceKey) ?? []; + final localGenderJson = + preferences.getString(_studentGendersPreferenceKey) ?? '{}'; + final localGenders = _decodeStudentGenders(localGenderJson); + final savedResultData = await _loadStudentDataFromSavedResults(); + final names = _normalizeStudentNames([ + ...localNames, + ...savedResultData.names, + ]); + final genders = _mergeStudentGenders( + localGenders, + savedResultData.genders, + ); + await preferences.setStringList(_studentNamesPreferenceKey, names); + await preferences.setString( + _studentGendersPreferenceKey, + jsonEncode(genders), + ); + if (!mounted) { + return; + } + setState(() { + _studentNames = names; + _studentGenders = genders; + }); + } catch (_) {} + } + + Future<_StudentData> _loadStudentDataFromSavedResults() async { + try { + final response = await http + .get(_apiEndpoint('/predictions')) + .timeout(const Duration(seconds: 12)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + return const _StudentData(names: [], genders: {}); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + final names = []; + final genders = {}; + for (final item in data) { + final row = (item as Map).cast(); + final name = _cleanStudentName(row['student_name']?.toString() ?? ''); + final gender = _cleanStudentGender( + row['student_gender']?.toString() ?? '', + ); + if (name.isEmpty) { + continue; + } + names.add(name); + if (gender.isNotEmpty) { + genders[name.toLowerCase()] = gender; + } + } + return _StudentData(names: names, genders: genders); + } catch (_) { + return const _StudentData(names: [], genders: {}); + } + } + + Map _decodeStudentGenders(String rawJson) { + try { + final decoded = jsonDecode(rawJson) as Map; + return decoded.map( + (key, value) => + MapEntry(key.toLowerCase(), _cleanStudentGender(value.toString())), + )..removeWhere((_, value) => value.isEmpty); + } catch (_) { + return {}; + } + } + + Map _mergeStudentGenders( + Map localGenders, + Map savedGenders, + ) { + return {...savedGenders, ...localGenders} + ..removeWhere((_, value) => value.isEmpty); + } + + List _normalizeStudentNames(Iterable names) { + final seen = {}; + final normalized = []; + for (final name in names) { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + continue; + } + final key = cleanName.toLowerCase(); + if (seen.add(key)) { + normalized.add(cleanName); + } + } + normalized.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return normalized; + } + + String _cleanStudentName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + String _cleanStudentGender(String gender) { + final cleanGender = gender.trim(); + return cleanGender == 'Laki-laki' || cleanGender == 'Perempuan' + ? cleanGender + : ''; + } + + Future _rememberStudentName(String name, {String? gender}) async { + final cleanName = _cleanStudentName(name); + if (cleanName.isEmpty) { + return; + } + + final cleanGender = _cleanStudentGender(gender ?? _studentGender); + final updated = _normalizeStudentNames([..._studentNames, cleanName]); + final updatedGenders = Map.from(_studentGenders); + if (cleanGender.isNotEmpty) { + updatedGenders[cleanName.toLowerCase()] = cleanGender; + } + final preferences = await SharedPreferences.getInstance(); + await preferences.setStringList(_studentNamesPreferenceKey, updated); + await preferences.setString( + _studentGendersPreferenceKey, + jsonEncode(updatedGenders), + ); + if (mounted) { + setState(() { + _studentNames = updated; + _studentGenders = updatedGenders; + _studentNameController.text = cleanName; + if (cleanGender.isNotEmpty) { + _studentGender = cleanGender; + } + }); + } + } + + void _applyStudentGenderFromName(String name) { + final gender = _studentGenders[_cleanStudentName(name).toLowerCase()]; + if (gender == null || gender.isEmpty) { + return; + } + setState(() { + _studentGender = gender; + _prediction = null; + _errorMessage = null; + }); + } + + bool get _hasStoredGenderForCurrentStudent { + final cleanName = _cleanStudentName(_studentNameController.text); + if (cleanName.isEmpty) { + return false; + } + return _studentGenders[cleanName.toLowerCase()]?.isNotEmpty == true; + } + + Future _pickAudio() async { + final audio = await pickAudio(supportedExtensions); + if (audio == null) { + return; + } + + final preview = await buildWavePreview(audio); + + setState(() { + _selectedAudio = audio; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } + + Future _toggleRecording() async { + if (_isRecording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + + Future _startRecording() async { + try { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() { + _errorMessage = 'Izin microphone belum diberikan.'; + }); + return; + } + + final supportsWav = await _recorder.isEncoderSupported(AudioEncoder.wav); + if (!supportsWav) { + setState(() { + _errorMessage = 'Perangkat ini belum mendukung rekaman WAV.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final recordingPath = recordingFilePath( + 'confivoice_recording_$timestamp.wav', + ); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 22050, + numChannels: 1, + noiseSuppress: true, + ), + path: recordingPath, + ); + + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder + .onAmplitudeChanged(const Duration(milliseconds: 90)) + .listen((amplitude) { + if (!mounted || !_isRecording) { + return; + } + final normalized = ((amplitude.current + 55) / 55).clamp(0.04, 1.0); + final nextValues = [..._liveWavePreview, normalized.toDouble()]; + setState(() { + _liveWavePreview = + nextValues.length > 48 + ? nextValues.sublist(nextValues.length - 48) + : nextValues; + }); + }); + + setState(() { + _isRecording = true; + _selectedAudio = null; + _liveWavePreview = List.filled(24, 0.06); + _wavePreview = const []; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _errorMessage = 'Gagal mulai rekam: $error'; + }); + } + } + + Future _stopRecording() async { + try { + await _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + final path = await _recorder.stop(); + if (path == null) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Rekaman gagal disimpan.'; + }); + return; + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final audio = await audioFromRecorderPath( + path, + 'confivoice_recording_$timestamp.wav', + ); + if (audio == null) { + setState(() { + _isRecording = false; + _errorMessage = 'Rekaman gagal dibaca.'; + }); + return; + } + final preview = await buildWavePreview(audio); + + setState(() { + _isRecording = false; + _selectedAudio = audio; + _liveWavePreview = const []; + _wavePreview = preview; + _prediction = null; + _errorMessage = null; + }); + } catch (error) { + setState(() { + _isRecording = false; + _liveWavePreview = const []; + _errorMessage = 'Gagal berhenti rekam: $error'; + }); + } + } + + Future _predictAudio() async { + final audio = _selectedAudio; + if (audio == null) { + setState(() { + _errorMessage = 'Pilih audio terlebih dahulu.'; + }); + return; + } + + final studentName = _studentNameController.text.trim(); + if (studentName.isEmpty) { + setState(() { + _errorMessage = 'Isi nama siswa/i terlebih dahulu.'; + }); + return; + } + if (_studentGender.isEmpty) { + setState(() { + _errorMessage = 'Pilih jenis kelamin siswa/i terlebih dahulu.'; + }); + return; + } + + final endpoint = Uri.tryParse(_apiController.text.trim()); + if (endpoint == null || !endpoint.hasScheme || endpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + setState(() { + _isPredicting = true; + _errorMessage = null; + }); + + try { + final request = http.MultipartRequest('POST', endpoint); + request.fields['student_name'] = studentName; + request.fields['student_gender'] = _studentGender; + request.files.add(await multipartFileFromAudio('file', audio)); + + final response = await request.send().timeout( + const Duration(seconds: 60), + ); + final responseBody = await response.stream.bytesToString(); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _PredictionApiException( + response.statusCode, + _readApiErrorMessage(responseBody), + ); + } + + final decoded = jsonDecode(responseBody) as Map; + final result = PredictionResult.fromJson(decoded); + await _saveApiEndpoint(); + + setState(() { + _prediction = result; + }); + } on _PredictionApiException catch (error) { + setState(() { + _errorMessage = 'Gagal prediksi: ${error.message}'; + }); + } catch (error) { + setState(() { + final endpointText = _apiController.text.trim(); + if (endpointText.contains('10.0.2.2')) { + _errorMessage = + 'Gagal prediksi: endpoint 10.0.2.2 hanya untuk emulator Android. ' + 'Jika memakai HP fisik, buka ikon pengaturan lalu ganti URL prediksi ' + 'ke alamat IP komputer yang menjalankan API, misalnya ' + 'http://192.168.1.6:8000/predict.'; + } else { + _errorMessage = + 'Gagal prediksi: tidak bisa terhubung ke API. Pastikan API berjalan, ' + 'HP dan komputer berada di WiFi yang sama, lalu cek URL di pengaturan. Detail: $error'; + } + }); + } finally { + if (mounted) { + setState(() { + _isPredicting = false; + }); + } + } + } + + Future _savePrediction() async { + final prediction = _prediction; + if (prediction == null || !prediction.isValidAudio) { + setState(() { + _errorMessage = 'Belum ada hasil analisis yang bisa disimpan.'; + }); + return; + } + + final predictEndpoint = Uri.tryParse(_apiController.text.trim()); + if (predictEndpoint == null || + !predictEndpoint.hasScheme || + predictEndpoint.host.isEmpty) { + setState(() { + _errorMessage = 'Endpoint API tidak valid.'; + }); + return; + } + + final saveEndpoint = _apiEndpoint('/predictions'); + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + var response = await http + .post( + saveEndpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 405) { + response = await http + .post( + _apiEndpoint('/save'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + prediction.toSaveJson(widget.session.id, _studentGender), + ), + ) + .timeout(const Duration(seconds: 30)); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Hasil analisis berhasil disimpan.')), + ); + + await _rememberStudentName( + prediction.studentName, + gender: _studentGender, + ); + _studentNameController.clear(); + setState(() { + _studentGender = ''; + _selectedAudio = null; + _liveWavePreview = const []; + _wavePreview = const []; + _prediction = null; + }); + } catch (error) { + setState(() { + _errorMessage = 'Gagal menyimpan hasil analisis: $error'; + }); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Uri _apiEndpoint(String path, {Map? queryParameters}) { + return Uri.parse( + _apiController.text.trim(), + ).replace(path: path, queryParameters: queryParameters); + } + + void _showSavedAnalyses() { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => SavedAnalysesPage( + endpoint: _apiEndpoint( + '/predictions', + queryParameters: {'limit': '1000'}, + ), + ), + ), + ); + } + + Future _confirmLogout() async { + final shouldLogout = await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Logout?'), + content: const Text('Sesi akun akan ditutup dari aplikasi ini.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.logout, size: 15), + label: const Text('Logout'), + ), + ], + ), + ); + + if (shouldLogout == true) { + await widget.onLogout(); + } + } + + @override + Widget build(BuildContext context) { + final displayUserName = + widget.session.fullName.trim().isEmpty + ? widget.session.username + : widget.session.fullName.trim(); + + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.graphic_eq, color: AppColors.blueSoft), + SizedBox(width: 8), + Text('ConfiVoice'), + ], + ), + actions: [ + IconButton( + tooltip: 'Lihat analisis', + onPressed: _showSavedAnalyses, + icon: const Icon(Icons.history_outlined), + ), + IconButton( + tooltip: 'Pengaturan API', + onPressed: _showApiSettings, + icon: const Icon(Icons.settings_outlined), + ), + Padding( + padding: const EdgeInsets.only(right: 12), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 92), + child: Text( + displayUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.text, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ], + ), + body: SafeArea( + child: _PredictionPage( + studentNameController: _studentNameController, + studentNames: _studentNames, + studentGender: _studentGender, + hasStoredStudentGender: _hasStoredGenderForCurrentStudent, + selectedAudio: _selectedAudio, + liveWavePreview: _liveWavePreview, + wavePreview: _wavePreview, + prediction: _prediction, + errorMessage: _errorMessage, + isPredicting: _isPredicting, + isSaving: _isSaving, + isRecording: _isRecording, + onAddStudentName: _rememberStudentName, + onSelectStudentName: _applyStudentGenderFromName, + onGenderChanged: (value) { + setState(() { + _studentGender = value ?? ''; + _prediction = null; + _errorMessage = null; + }); + }, + onPickAudio: _pickAudio, + onToggleRecording: _toggleRecording, + onPredict: _predictAudio, + onSave: _savePrediction, + ), + ), + ); + } + + Future _showApiSettings() async { + await showDialog( + context: context, + builder: + (context) => AlertDialog( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: AppColors.border), + ), + title: const Text('Pengaturan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _apiController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'URL prediksi', + ), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () { + Navigator.pop(context); + _confirmLogout(); + }, + child: const Text('Logout'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + FilledButton.icon( + onPressed: () async { + await _saveApiEndpoint(); + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Simpan'), + ), + ], + ), + ); + } +} + +class _StudentData { + const _StudentData({required this.names, required this.genders}); + + final List names; + final Map genders; +} + +class _PredictionApiException implements Exception { + const _PredictionApiException(this.statusCode, this.message); + + final int statusCode; + final String message; + + @override + String toString() => 'API error $statusCode: $message'; +} + +String _readApiErrorMessage(String responseBody) { + try { + final decoded = jsonDecode(responseBody) as Map; + final detail = decoded['detail']; + if (detail is String && detail.trim().isNotEmpty) { + return detail.trim(); + } + if (detail != null) { + return detail.toString(); + } + } catch (_) {} + return responseBody.trim().isEmpty + ? 'Server mengembalikan respons kosong.' + : responseBody.trim(); +} diff --git a/cv_app/mobile_app/lib/fitur/prediction/prediction_page.dart b/cv_app/mobile_app/lib/fitur/prediction/prediction_page.dart new file mode 100644 index 0000000..ae4881c --- /dev/null +++ b/cv_app/mobile_app/lib/fitur/prediction/prediction_page.dart @@ -0,0 +1,950 @@ +part of '../../main.dart'; + +class _PredictionPage extends StatelessWidget { + const _PredictionPage({ + required this.studentNameController, + required this.studentNames, + required this.studentGender, + required this.hasStoredStudentGender, + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.prediction, + required this.errorMessage, + required this.isPredicting, + required this.isSaving, + required this.isRecording, + required this.onAddStudentName, + required this.onSelectStudentName, + required this.onGenderChanged, + required this.onPickAudio, + required this.onToggleRecording, + required this.onPredict, + required this.onSave, + }); + + final TextEditingController studentNameController; + final List studentNames; + final String studentGender; + final bool hasStoredStudentGender; + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final PredictionResult? prediction; + final String? errorMessage; + final bool isPredicting; + final bool isSaving; + final bool isRecording; + final Future Function(String name) onAddStudentName; + final ValueChanged onSelectStudentName; + final ValueChanged onGenderChanged; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final VoidCallback onPredict; + final VoidCallback onSave; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.navy, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: const Icon( + Icons.record_voice_over_outlined, + color: AppColors.blueSoft, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Analisis Percaya Diri', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Isi nama siswa/i, pilih atau rekam audio, lalu lihat hasil analisis.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentNameSearchField( + controller: studentNameController, + names: studentNames, + onAddName: onAddStudentName, + onSelectName: onSelectStudentName, + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: _StudentGenderField( + value: studentGender, + isStored: hasStoredStudentGender, + onChanged: onGenderChanged, + ), + ), + ), + const SizedBox(height: 16), + _AudioCard( + selectedAudio: selectedAudio, + liveWavePreview: liveWavePreview, + wavePreview: wavePreview, + onPickAudio: onPickAudio, + onToggleRecording: onToggleRecording, + isRecording: isRecording, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isPredicting || isRecording ? null : onPredict, + icon: + isPredicting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.analytics_outlined), + label: Text(isPredicting ? 'Memproses...' : 'Prediksi'), + ), + if (errorMessage != null) ...[ + const SizedBox(height: 12), + _WarningBox(message: errorMessage!), + ], + if (prediction != null) ...[ + const SizedBox(height: 16), + _PredictionResultCard(result: prediction!), + if (prediction!.isValidAudio) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: isSaving ? null : onSave, + icon: + isSaving + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? 'Menyimpan...' : 'Simpan Hasil'), + ), + ], + ], + ], + ); + } +} + +class _StudentNameSearchField extends StatefulWidget { + const _StudentNameSearchField({ + required this.controller, + required this.names, + required this.onAddName, + required this.onSelectName, + }); + + final TextEditingController controller; + final List names; + final Future Function(String name) onAddName; + final ValueChanged onSelectName; + + @override + State<_StudentNameSearchField> createState() => + _StudentNameSearchFieldState(); +} + +class _StudentGenderField extends StatelessWidget { + const _StudentGenderField({ + required this.value, + required this.isStored, + required this.onChanged, + }); + + final String value; + final bool isStored; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (isStored && value.isNotEmpty) { + return InputDecorator( + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + child: Row( + children: [ + Expanded(child: Text(value)), + const Text( + 'Tersimpan', + style: TextStyle(color: AppColors.blueSoft), + ), + ], + ), + ); + } + + return DropdownButtonFormField( + value: value.isEmpty ? null : value, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.wc_outlined), + labelText: 'Jenis kelamin siswa/i', + ), + items: const [ + DropdownMenuItem(value: 'Laki-laki', child: Text('Laki-laki')), + DropdownMenuItem(value: 'Perempuan', child: Text('Perempuan')), + ], + onChanged: onChanged, + ); + } +} + +class _StudentNameSearchFieldState extends State<_StudentNameSearchField> { + final FocusNode _focusNode = FocusNode(); + String _query = ''; + + @override + void initState() { + super.initState(); + _query = widget.controller.text; + _focusNode.addListener(_handleFocusChanged); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChanged) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _StudentNameSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _query = widget.controller.text; + } + } + + void _handleFocusChanged() { + if (mounted) { + setState(() {}); + } + } + + String _cleanName(String name) { + return name + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + } + + List get _matches { + final cleanQuery = _cleanName(_query).toLowerCase(); + if (cleanQuery.isEmpty) { + return widget.names.take(5).toList(); + } + return widget.names + .where((name) => name.toLowerCase().contains(cleanQuery)) + .take(5) + .toList(); + } + + bool get _hasExactMatch { + final cleanQuery = _cleanName(_query); + return cleanQuery.isNotEmpty && + widget.names.any( + (name) => name.toLowerCase() == cleanQuery.toLowerCase(), + ); + } + + bool get _shouldShowPanel { + return _focusNode.hasFocus && + (_matches.isNotEmpty || _cleanName(_query).isNotEmpty); + } + + Future _addCurrentName() async { + final cleanName = _cleanName(_query); + if (cleanName.isEmpty) { + return; + } + await widget.onAddName(cleanName); + widget.controller.text = cleanName; + widget.controller.selection = TextSelection.collapsed( + offset: cleanName.length, + ); + if (mounted) { + setState(() { + _query = cleanName; + }); + } + } + + void _handleAddNameTap() { + _addCurrentName(); + _focusNode.unfocus(); + } + + void _selectName(String name) { + widget.controller.text = name; + widget.controller.selection = TextSelection.collapsed(offset: name.length); + setState(() { + _query = name; + }); + widget.onSelectName(name); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final matches = _matches; + final cleanQuery = _cleanName(_query); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.controller, + focusNode: _focusNode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), + labelText: 'Cari atau tambah nama siswa/i', + ), + textInputAction: TextInputAction.next, + onChanged: (value) { + setState(() { + _query = value; + }); + }, + ), + if (_shouldShowPanel) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final name in matches) + _StudentNameOption( + icon: Icons.person_outline, + label: name, + onSelect: () => _selectName(name), + ), + if (cleanQuery.isNotEmpty && !_hasExactMatch) + _StudentNameOption( + icon: Icons.add_circle_outline, + label: 'Tambah "$cleanQuery"', + onSelect: _handleAddNameTap, + ), + ], + ), + ), + ], + ], + ); + } +} + +class _StudentNameOption extends StatelessWidget { + const _StudentNameOption({ + required this.icon, + required this.label, + required this.onSelect, + }); + + final IconData icon; + final String label; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTapDown: (_) => onSelect(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, color: AppColors.blueSoft, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AudioCard extends StatelessWidget { + const _AudioCard({ + required this.selectedAudio, + required this.liveWavePreview, + required this.wavePreview, + required this.onPickAudio, + required this.onToggleRecording, + required this.isRecording, + }); + + final SelectedAudio? selectedAudio; + final List liveWavePreview; + final List wavePreview; + final VoidCallback onPickAudio; + final VoidCallback onToggleRecording; + final bool isRecording; + + @override + Widget build(BuildContext context) { + final file = selectedAudio; + final displayWave = isRecording ? liveWavePreview : wavePreview; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.audio_file, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + file == null ? 'Belum ada audio' : file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: 120, + child: WaveformPreview(values: displayWave, isLive: isRecording), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isRecording ? null : onPickAudio, + icon: const Icon(Icons.folder_open), + label: const Text('Pilih Audio'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onToggleRecording, + icon: Icon( + isRecording ? Icons.stop : Icons.fiber_manual_record, + ), + label: Text(isRecording ? 'Stop' : 'Rekam'), + ), + ), + ], + ), + if (isRecording) ...[ + const SizedBox(height: 10), + const _RecordingBanner(), + ], + ], + ), + ), + ); + } +} + +class _RecordingBanner extends StatelessWidget { + const _RecordingBanner(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.recordingBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.recordingBorder), + ), + child: const Row( + children: [ + Icon(Icons.mic, color: AppColors.recording), + SizedBox(width: 10), + Expanded( + child: Text('Sedang merekam suara... tekan Stop setelah selesai.'), + ), + ], + ), + ); + } +} + +class _PredictionResultCard extends StatelessWidget { + const _PredictionResultCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + if (!result.isValidAudio) { + return _InvalidAudioCard(result: result); + } + + final pdConfidencePercent = (result.probabilityPd * 100).toStringAsFixed(2); + final lowConfidence = result.confidence < 0.60; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil Prediksi', + style: Theme.of(context).textTheme.titleLarge, + ), + if (result.studentName.isNotEmpty || result.id != null) ...[ + const SizedBox(height: 8), + Text( + [ + if (result.studentName.isNotEmpty) result.studentName, + result.genderLabel, + if (result.id != null) 'ID #${result.id}', + ].join(' - '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricBox(label: 'Label', value: result.label), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricBox( + label: 'Confidence', + value: '$pdConfidencePercent%', + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + result.description, + style: Theme.of(context).textTheme.titleMedium, + ), + if (result.explanation.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(result.explanation), + ], + const SizedBox(height: 16), + _ProbabilityBar(label: 'PD', value: result.probabilityPd), + const SizedBox(height: 10), + _ProbabilityBar(label: 'TPD', value: result.probabilityTpd), + const SizedBox(height: 12), + _DetailRow( + label: 'Kualitas audio', + value: _qualityStatus(result.audioQuality), + ), + if (lowConfidence) ...[ + const SizedBox(height: 12), + const _WarningBox( + message: + 'Model belum yakin. Confidence di bawah 60%, coba rekam ulang dengan suara lebih jelas.', + ), + ], + ], + ), + ), + ); + } +} + +class _InvalidAudioCard extends StatelessWidget { + const _InvalidAudioCard({required this.result}); + + final PredictionResult result; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Audio Tidak Valid', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + _WarningBox(message: result.errorMessage ?? result.explanation), + const SizedBox(height: 12), + const Text( + 'Silakan rekam ulang dengan durasi minimal 2 detik, suara jelas, dan volume normal.', + ), + ], + ), + ), + ); + } +} + +String _qualityStatus(AudioQuality quality) { + if (quality.isTooShort) { + return 'Terlalu pendek'; + } + if (quality.isClipped) { + return 'Terlalu keras'; + } + if (quality.isTooQuiet) { + return 'Terlalu pelan'; + } + return 'Baik'; +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } +} + +class _MetricBox extends StatelessWidget { + const _MetricBox({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ); + } +} + +class _ProbabilityBar extends StatelessWidget { + const _ProbabilityBar({required this.label, required this.value}); + + final String label; + final double value; + + @override + Widget build(BuildContext context) { + final percent = (value * 100).clamp(0, 100).toStringAsFixed(2); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(label), Text('$percent%')], + ), + const SizedBox(height: 6), + LinearProgressIndicator(value: value.clamp(0.0, 1.0), minHeight: 10), + ], + ); + } +} + +class _WarningBox extends StatelessWidget { + const _WarningBox({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.warningBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.warningBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: AppColors.warningText), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: AppColors.warningText), + ), + ), + ], + ), + ); + } +} + +class WaveformPreview extends StatefulWidget { + const WaveformPreview({super.key, required this.values, this.isLive = false}); + + final List values; + final bool isLive; + + @override + State createState() => _WaveformPreviewState(); +} + +class _WaveformPreviewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + if (widget.isLive) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant WaveformPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isLive && !_controller.isAnimating) { + _controller.repeat(); + } else if (!widget.isLive && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final emptyText = + widget.isLive + ? 'Mulai berbicara, volume suara akan bergerak di sini.' + : 'Preview waveform akan muncul setelah audio dipilih.'; + + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceRaised, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + widget.isLive + ? AppColors.blue.withValues(alpha: 0.45) + : AppColors.border, + ), + ), + child: + widget.isLive + ? AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _LiveFrequencyPainter( + progress: _controller.value, + color: AppColors.blueSoft, + ), + ); + }, + ) + : widget.values.isEmpty + ? Center(child: Text(emptyText, textAlign: TextAlign.center)) + : CustomPaint( + painter: _WaveformPainter( + values: widget.values, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + const _WaveformPainter({required this.values, required this.color}); + + final List values; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final axisPaint = + Paint() + ..color = AppColors.border + ..strokeWidth = 1; + canvas.drawLine(Offset(0, centerY), Offset(size.width, centerY), axisPaint); + + if (values.isEmpty) { + return; + } + + final paint = + Paint() + ..color = color + ..strokeWidth = max(2, size.width / values.length * 0.45) + ..strokeCap = StrokeCap.round; + final gap = size.width / values.length; + + for (var i = 0; i < values.length; i++) { + final x = gap * i + gap / 2; + final amplitude = values[i].abs() * centerY * 0.9; + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _WaveformPainter oldDelegate) { + return oldDelegate.values != values || oldDelegate.color != color; + } +} + +class _LiveFrequencyPainter extends CustomPainter { + const _LiveFrequencyPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final barCount = 34; + final gap = size.width / barCount; + final barWidth = max(3.0, gap * 0.48); + final phase = progress * pi * 2; + + final backgroundPaint = + Paint() + ..color = AppColors.blue.withValues(alpha: 0.10) + ..strokeWidth = 1; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + backgroundPaint, + ); + + final glowPaint = + Paint() + ..color = color.withValues(alpha: 0.20) + ..strokeWidth = barWidth * 2.4 + ..strokeCap = StrokeCap.round; + final barPaint = + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.blueSoft.withValues(alpha: 0.95), + AppColors.blue, + AppColors.blueSoft.withValues(alpha: 0.95), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..strokeWidth = barWidth + ..strokeCap = StrokeCap.round; + + for (var i = 0; i < barCount; i++) { + final x = gap * i + gap / 2; + final waveA = sin(phase + i * 0.45); + final waveB = sin(phase * 1.7 - i * 0.23); + final envelope = 0.52 + 0.48 * sin((i / barCount) * pi); + final heightFactor = + (0.34 + 0.30 * waveA.abs() + 0.20 * waveB.abs()) * envelope; + final amplitude = max(8.0, centerY * heightFactor); + + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + glowPaint, + ); + canvas.drawLine( + Offset(x, centerY - amplitude), + Offset(x, centerY + amplitude), + barPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _LiveFrequencyPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} diff --git a/cv_app/mobile_app/lib/fitur/saved_analyses/saved_analyses_page.dart b/cv_app/mobile_app/lib/fitur/saved_analyses/saved_analyses_page.dart new file mode 100644 index 0000000..16a9536 --- /dev/null +++ b/cv_app/mobile_app/lib/fitur/saved_analyses/saved_analyses_page.dart @@ -0,0 +1,219 @@ +part of '../../main.dart'; + +class SavedAnalysesPage extends StatefulWidget { + const SavedAnalysesPage({super.key, required this.endpoint}); + + final Uri endpoint; + + @override + State createState() => _SavedAnalysesPageState(); +} + +class _SavedAnalysesPageState extends State { + late Future> _analyses; + + @override + void initState() { + super.initState(); + _analyses = _loadAnalyses(); + } + + Future> _loadAnalyses() async { + final response = await http + .get(widget.endpoint) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('API error ${response.statusCode}: ${response.body}'); + } + + final decoded = jsonDecode(response.body) as Map; + final data = decoded['data'] as List? ?? []; + return data + .map( + (item) => + SavedAnalysis.fromJson((item as Map).cast()), + ) + .toList(); + } + + Future _refresh() async { + setState(() { + _analyses = _loadAnalyses(); + }); + await _analyses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lihat Analisis')), + body: FutureBuilder>( + future: _analyses, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 40), + const SizedBox(height: 12), + Text( + 'Gagal memuat data analisis.\n${snapshot.error}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ], + ), + ), + ); + } + + final analyses = snapshot.data ?? []; + if (analyses.isEmpty) { + return const Center(child: Text('Belum ada analisis tersimpan.')); + } + final groups = StudentAnalysisGroup.fromAnalyses(analyses); + + return RefreshIndicator( + onRefresh: _refresh, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: groups.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final group = groups[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: const Icon(Icons.folder_outlined), + ), + title: Text(group.studentName), + subtitle: Text( + '${group.latest.genderLabel} | ${group.count} hasil analisis\nRata-rata PD: ${group.averagePdPercent}%', + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: + (context) => + StudentAnalysisDetailPage(group: group), + ), + ); + }, + ), + ); + }, + ), + ); + }, + ), + ); + } +} + +class StudentAnalysisGroup { + const StudentAnalysisGroup({ + required this.studentName, + required this.analyses, + }); + + final String studentName; + final List analyses; + + SavedAnalysis get latest => analyses.first; + int get count => analyses.length; + String get averagePdPercent { + if (analyses.isEmpty) { + return '0.0'; + } + final average = + analyses.fold( + 0, + (total, analysis) => total + analysis.probabilityPd, + ) / + analyses.length; + return (average * 100).toStringAsFixed(1); + } + + static List fromAnalyses(List analyses) { + final grouped = >{}; + final displayNames = {}; + + for (final analysis in analyses) { + final name = + analysis.studentName.trim().isEmpty + ? '-' + : analysis.studentName.trim(); + final key = name.toLowerCase(); + displayNames.putIfAbsent(key, () => name); + grouped.putIfAbsent(key, () => []).add(analysis); + } + + final groups = + grouped.entries + .map( + (entry) => StudentAnalysisGroup( + studentName: displayNames[entry.key] ?? entry.key, + analyses: entry.value, + ), + ) + .toList(); + groups.sort( + (a, b) => + a.studentName.toLowerCase().compareTo(b.studentName.toLowerCase()), + ); + return groups; + } +} + +class StudentAnalysisDetailPage extends StatelessWidget { + const StudentAnalysisDetailPage({super.key, required this.group}); + + final StudentAnalysisGroup group; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(group.studentName)), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: group.analyses.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final analysis = group.analyses[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.navy, + foregroundColor: AppColors.blueSoft, + child: Text(analysis.label), + ), + title: Text('Prediksi ${index + 1}'), + subtitle: Text( + '${analysis.genderLabel} | ${analysis.description}\nPD ${analysis.pdPercent}% | TPD ${analysis.tpdPercent}%', + ), + isThreeLine: true, + ), + ); + }, + ), + ); + } +} diff --git a/cv_app/mobile_app/lib/main.dart b/cv_app/mobile_app/lib/main.dart new file mode 100644 index 0000000..dfd6095 --- /dev/null +++ b/cv_app/mobile_app/lib/main.dart @@ -0,0 +1,36 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:record/record.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'audio_source.dart'; + +part 'app/app.dart'; +part 'fitur/auth/auth.dart'; +part 'fitur/home/confivoice_shell.dart'; +part 'fitur/saved_analyses/saved_analyses_page.dart'; +part 'models/analysis_models.dart'; +part 'fitur/prediction/prediction_page.dart'; + +void main() { + runApp(const ConfiVoiceMobileApp()); +} + +String defaultApiEndpoint() { + return kIsWeb + ? 'http://127.0.0.1:8000/predict' + : 'http://MacBook-Pro-2.local:8000/predict'; +} + +String normalizeApiEndpoint(String endpoint) { + final cleanEndpoint = endpoint.trim(); + if (kIsWeb && cleanEndpoint.toLowerCase().contains('macbook-pro-2.local')) { + return 'http://127.0.0.1:8000/predict'; + } + return cleanEndpoint; +} diff --git a/cv_app/mobile_app/lib/models/analysis_models.dart b/cv_app/mobile_app/lib/models/analysis_models.dart new file mode 100644 index 0000000..090e066 --- /dev/null +++ b/cv_app/mobile_app/lib/models/analysis_models.dart @@ -0,0 +1,138 @@ +part of '../main.dart'; + +class SavedAnalysis { + const SavedAnalysis({ + required this.studentName, + required this.studentGender, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + }); + + factory SavedAnalysis.fromJson(Map json) { + return SavedAnalysis( + studentName: json['student_name']?.toString() ?? '-', + studentGender: json['student_gender']?.toString() ?? '', + label: json['predicted_label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: PredictionResult._asDouble(json['confidence']), + probabilityPd: PredictionResult._asDouble(json['probability_pd']), + probabilityTpd: PredictionResult._asDouble(json['probability_tpd']), + ); + } + + final String studentName; + final String studentGender; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + + String get confidencePercent => (confidence * 100).toStringAsFixed(1); + String get pdPercent => (probabilityPd * 100).toStringAsFixed(1); + String get tpdPercent => (probabilityTpd * 100).toStringAsFixed(1); + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class PredictionResult { + const PredictionResult({ + required this.id, + required this.studentName, + required this.studentGender, + required this.isValidAudio, + required this.errorMessage, + required this.label, + required this.description, + required this.confidence, + required this.probabilityPd, + required this.probabilityTpd, + required this.audioQuality, + required this.explanation, + required this.rawJson, + }); + + factory PredictionResult.fromJson(Map json) { + final probabilities = + (json['probabilities'] as Map?)?.cast() ?? {}; + final predictedLabel = json['predicted_label']?.toString(); + return PredictionResult( + id: + json['prediction_id'] is int + ? json['prediction_id'] as int + : int.tryParse(json['prediction_id']?.toString() ?? ''), + studentName: json['student_name']?.toString() ?? '', + studentGender: json['student_gender']?.toString() ?? '', + isValidAudio: json['is_valid_audio'] != false, + errorMessage: json['error_message']?.toString(), + label: predictedLabel ?? json['label']?.toString() ?? '-', + description: json['description']?.toString() ?? '-', + confidence: _asDouble(json['confidence']), + probabilityPd: _asDouble(json['probability_pd'] ?? probabilities['PD']), + probabilityTpd: _asDouble( + json['probability_tpd'] ?? probabilities['TPD'], + ), + audioQuality: AudioQuality.fromJson( + (json['audio_quality'] as Map?)?.cast() ?? {}, + ), + explanation: json['explanation']?.toString() ?? '', + rawJson: json, + ); + } + + Map toSaveJson(int userId, String studentGender) { + return { + ...rawJson, + 'student_name': studentName, + 'student_gender': studentGender, + 'user_id': userId, + }; + } + + static double _asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + final int? id; + final String studentName; + final String studentGender; + final bool isValidAudio; + final String? errorMessage; + final String label; + final String description; + final double confidence; + final double probabilityPd; + final double probabilityTpd; + final AudioQuality audioQuality; + final String explanation; + final Map rawJson; + + String get genderLabel => + studentGender.isEmpty ? 'Belum diisi' : studentGender; +} + +class AudioQuality { + const AudioQuality({ + required this.isClipped, + required this.isTooQuiet, + required this.isTooShort, + }); + + factory AudioQuality.fromJson(Map json) { + return AudioQuality( + isClipped: json['is_clipped'] == true, + isTooQuiet: json['is_too_quiet'] == true, + isTooShort: json['is_too_short'] == true, + ); + } + + final bool isClipped; + final bool isTooQuiet; + final bool isTooShort; +} diff --git a/cv_app/mobile_app/pubspec.lock b/cv_app/mobile_app/pubspec.lock new file mode 100644 index 0000000..b03df25 --- /dev/null +++ b/cv_app/mobile_app/pubspec.lock @@ -0,0 +1,466 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + url: "https://pub.dev" + source: hosted + version: "0.3.5" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + url: "https://pub.dev" + source: hosted + version: "2.0.31" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.dev" + source: hosted + version: "10.0.8" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.7.2 <4.0.0" + flutter: ">=3.29.0" diff --git a/cv_app/mobile_app/pubspec.yaml b/cv_app/mobile_app/pubspec.yaml new file mode 100644 index 0000000..58b7428 --- /dev/null +++ b/cv_app/mobile_app/pubspec.yaml @@ -0,0 +1,91 @@ +name: confivoice_mobile +description: "ConfiVoice mobile app for Android prediction client." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.7.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + file_picker: ^8.1.7 + http: ^1.2.2 + record: ^6.2.1 + shared_preferences: ^2.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/cv_app/mobile_app/test/widget_test.dart b/cv_app/mobile_app/test/widget_test.dart new file mode 100644 index 0000000..a7e8845 --- /dev/null +++ b/cv_app/mobile_app/test/widget_test.dart @@ -0,0 +1,11 @@ +import 'package:confivoice_mobile/main.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ConfiVoice mobile renders home page', (WidgetTester tester) async { + await tester.pumpWidget(const ConfiVoiceMobileApp()); + + expect(find.text('ConfiVoice'), findsOneWidget); + expect(find.text('Mulai Prediksi'), findsOneWidget); + }); +} diff --git a/cv_app/mobile_app/web/favicon.png b/cv_app/mobile_app/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/cv_app/mobile_app/web/favicon.png differ diff --git a/cv_app/mobile_app/web/icons/Icon-192.png b/cv_app/mobile_app/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/cv_app/mobile_app/web/icons/Icon-192.png differ diff --git a/cv_app/mobile_app/web/icons/Icon-512.png b/cv_app/mobile_app/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/cv_app/mobile_app/web/icons/Icon-512.png differ diff --git a/cv_app/mobile_app/web/icons/Icon-maskable-192.png b/cv_app/mobile_app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/cv_app/mobile_app/web/icons/Icon-maskable-192.png differ diff --git a/cv_app/mobile_app/web/icons/Icon-maskable-512.png b/cv_app/mobile_app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/cv_app/mobile_app/web/icons/Icon-maskable-512.png differ diff --git a/cv_app/mobile_app/web/index.html b/cv_app/mobile_app/web/index.html new file mode 100644 index 0000000..715d43e --- /dev/null +++ b/cv_app/mobile_app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + confivoice_mobile + + + + + + diff --git a/cv_app/mobile_app/web/manifest.json b/cv_app/mobile_app/web/manifest.json new file mode 100644 index 0000000..e214ba8 --- /dev/null +++ b/cv_app/mobile_app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "confivoice_mobile", + "short_name": "confivoice_mobile", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/cv_app/predict_app.py b/cv_app/predict_app.py new file mode 100644 index 0000000..99c6b5f --- /dev/null +++ b/cv_app/predict_app.py @@ -0,0 +1,173 @@ +from pathlib import Path +import sys + +import joblib +import numpy as np + +from audio_utils_app import convert_audio_to_wav + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" +MODEL_DIR = ML_DIR / "models" +MODEL_PATH = MODEL_DIR / "svm_voice_confidence_model.joblib" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from features import ( # noqa: E402 + LABEL_PD, + LABEL_TPD, + analyze_audio, + build_prediction_explanation, +) + + +CONFIDENCE_THRESHOLD = 0.60 +MODEL_NOT_FOUND_MESSAGE = "Model tidak ditemukan. Pastikan file model berada di folder ml/models/." + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +def find_model_path(): + if MODEL_PATH.exists(): + return MODEL_PATH + + available_models = sorted(MODEL_DIR.glob("*.joblib")) + if available_models: + return available_models[0] + + raise FileNotFoundError(MODEL_NOT_FOUND_MESSAGE) + + +def load_prediction_model(model_path=None): + model_path = Path(model_path) if model_path else find_model_path() + if not model_path.exists(): + raise FileNotFoundError(MODEL_NOT_FOUND_MESSAGE) + return joblib.load(model_path) + + +def get_expected_feature_count(model): + if hasattr(model, "named_steps") and "scaler" in model.named_steps: + return getattr(model.named_steps["scaler"], "n_features_in_", None) + return getattr(model, "n_features_in_", None) + + +def calculate_indicator_probability(model_probability_pd, indicators): + indicator_pd = ( + 0.15 * indicators["volume_score"] + + 0.35 * indicators["intonation_score"] + + 0.50 * indicators["pause_score"] + ) + + adjusted_pd = 0.80 * model_probability_pd + 0.20 * indicator_pd + + if indicators["pause_score"] < 0.35: + adjusted_pd -= (0.35 - indicators["pause_score"]) * 0.25 + if indicators["volume_score"] < 0.18: + adjusted_pd -= (0.18 - indicators["volume_score"]) * 0.10 + if indicators["intonation_score"] < 0.45: + adjusted_pd -= (0.45 - indicators["intonation_score"]) * 0.15 + + return float(np.clip(adjusted_pd, 0.01, 0.99)), float(np.clip(indicator_pd, 0.0, 1.0)) + + +def predict_audio(audio_path, model_path=None): + """ + Prediksi audio dari aplikasi. + Model tetap dibaca dari ../ml/models dan fitur diambil dari ../ml/features.py. + """ + model = load_prediction_model(model_path) + wav_path = convert_audio_to_wav(audio_path) + + try: + try: + analysis = analyze_audio(wav_path, validate_quality=True) + except ValueError as error: + fallback_analysis = analyze_audio(wav_path, validate_quality=False) + return { + "is_valid_audio": False, + "error_message": str(error), + "predicted_label": None, + "description": "Audio tidak valid", + "confidence": 0.0, + "probability_pd": 0.0, + "probability_tpd": 0.0, + "margin": 0.0, + "audio_quality": fallback_analysis["audio_quality"], + "voice_indicators": fallback_analysis["voice_indicators"], + "explanation": str(error), + } + + features = analysis["features"] + feature_matrix = np.asarray(features, dtype=np.float32).reshape(1, -1) + + expected_feature_count = get_expected_feature_count(model) + if expected_feature_count and feature_matrix.shape[1] != expected_feature_count: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {feature_matrix.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_feature_count} fitur." + ) + + prediction = model.predict(feature_matrix) + probabilities = model.predict_proba(feature_matrix) + + predicted_label = str(prediction[0]) + class_probabilities = { + str(label): float(probability) + for label, probability in zip(model.classes_, probabilities[0]) + } + model_probability_pd = float(class_probabilities.get(LABEL_PD, 0.0)) + probability_pd, indicator_pd_score = calculate_indicator_probability( + model_probability_pd, + analysis["voice_indicators"], + ) + probability_tpd = float(1.0 - probability_pd) + predicted_label = LABEL_PD if probability_pd >= probability_tpd else LABEL_TPD + confidence = float(max(probability_pd, probability_tpd)) + margin = float(abs(probability_pd - probability_tpd)) + explanation = build_prediction_explanation( + predicted_label, + confidence, + analysis["voice_indicators"], + ) + + return { + "is_valid_audio": True, + "predicted_label": predicted_label, + "label": predicted_label, + "description": LABEL_DESCRIPTION.get(predicted_label, predicted_label), + "confidence": confidence, + "probability_pd": probability_pd, + "probability_tpd": probability_tpd, + "margin": margin, + "probabilities": { + LABEL_PD: probability_pd, + LABEL_TPD: probability_tpd, + }, + "audio_quality": analysis["audio_quality"], + "voice_indicators": analysis["voice_indicators"], + "indicator_pd_score": indicator_pd_score, + "model_probability_pd": model_probability_pd, + "model_probability_tpd": float(class_probabilities.get(LABEL_TPD, 0.0)), + "explanation": explanation, + } + finally: + Path(wav_path).unlink(missing_ok=True) + + +def get_model_info(): + model_path = find_model_path() + model = load_prediction_model(model_path) + + return { + "model_path": str(model_path.relative_to(PROJECT_DIR)), + "model_type": type(model).__name__, + "classes": [str(label) for label in getattr(model, "classes_", [])], + "expected_features": get_expected_feature_count(model), + } diff --git a/cv_app/requirements.txt b/cv_app/requirements.txt new file mode 100644 index 0000000..e927c2a --- /dev/null +++ b/cv_app/requirements.txt @@ -0,0 +1,16 @@ +numpy>=1.23,<2.0 +numba>=0.58,<0.60 +librosa==0.10.2.post1 +scikit-learn>=1.3,<1.6 +joblib>=1.3 +streamlit>=1.31 +soundfile>=0.12 +pydub>=0.25 +imageio-ffmpeg>=0.5 +ffmpeg-python>=0.2 +matplotlib +pandas +fastapi +uvicorn +python-multipart +PyMySQL diff --git a/cv_web/README.md b/cv_web/README.md new file mode 100644 index 0000000..6f990f6 --- /dev/null +++ b/cv_web/README.md @@ -0,0 +1,69 @@ +# ConfiVoice Admin Web + +Folder `cv_web/` berisi website admin untuk melihat hasil prediksi siswa/i. + +Website ini membaca database yang sama dengan API di folder `ml/`. + +## Menjalankan Admin Web + +```bash +cd cv_web +../cv_app/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8001 +``` + +Buka: + +```text +http://localhost:8001/admin +``` + +## Database phpMyAdmin / MySQL + +Mode default memakai MySQL/MariaDB agar data bisa dicek lewat phpMyAdmin. + +```text +database: confivoice +table: prediction_results +``` + +Nyalakan MySQL dari XAMPP/MAMP, lalu jalankan API dan admin web. API akan +membuat database dan tabel otomatis jika user MySQL punya izin. + +Konfigurasi default: + +```text +host: 127.0.0.1 +port: 3306 +user: root +password: kosong +``` + +Jika setting MySQL berbeda, jalankan API dan admin web dengan environment yang +sama: + +```bash +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice +``` + +Data berada di tabel: + +```text +prediction_results +``` + +Schema manual untuk phpMyAdmin: + +```text +cv_web/schema_mysql.sql +``` + +SQLite lama, jika masih ada, berada di: + +```text +cv_web/data/confivoice.db +``` diff --git a/cv_web/app.py b/cv_web/app.py new file mode 100644 index 0000000..f51c1cd --- /dev/null +++ b/cv_web/app.py @@ -0,0 +1,1993 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from html import escape +from pathlib import Path +import sys +from urllib.parse import quote, unquote + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +ML_DIR = PROJECT_DIR / "ml" + +if str(ML_DIR) not in sys.path: + sys.path.append(str(ML_DIR)) + +from database import ( # noqa: E402 + authenticate_user, + create_user, + delete_prediction_result, + delete_user, + get_database_label, + get_prediction_result, + get_user_by_id, + init_db, + list_prediction_results, + list_users, + save_prediction_result, + update_prediction_result, + update_user, +) + + +app = FastAPI(title="ConfiVoice Admin Web") +ADMIN_SESSION_COOKIE = "confivoice_admin_session" +ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 +ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.middleware("http") +async def require_admin_session(request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} + if path.startswith("/admin") and path not in public_admin_paths: + user = _get_admin_user_from_request(request) + if user is None: + return RedirectResponse(url="/admin/login", status_code=303) + request.state.admin_user = user + return await call_next(request) + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Admin Web", + "status": "ok", + "admin_page": "/admin", + "database": get_database_label(), + } + + +@app.get("/admin/login", response_class=HTMLResponse) +def read_admin_login_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return HTMLResponse(_auth_page(mode="login")) + + +@app.post("/admin/login", response_class=HTMLResponse) +def login_admin( + username: str = Form(...), + password: str = Form(...), +): + user = authenticate_user(username.strip().lower(), password) + if user is None: + return HTMLResponse( + _auth_page( + mode="login", + error_message="Username atau password salah.", + username=username, + ) + ) + + response = RedirectResponse(url="/admin", status_code=303) + response.set_cookie( + ADMIN_SESSION_COOKIE, + _create_admin_session_token(user), + max_age=ADMIN_SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + + +@app.get("/admin/register", response_class=HTMLResponse) +def read_admin_register_page(request: Request): + if _get_admin_user_from_request(request): + return RedirectResponse(url="/admin", status_code=303) + return HTMLResponse(_auth_page(mode="register")) + + +@app.post("/admin/register", response_class=HTMLResponse) +def register_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), +): + full_name = " ".join(full_name.strip().split()) + username = username.strip().lower() + password = password.strip() + confirm_password = confirm_password.strip() + + if not full_name or not username or not password or not confirm_password: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Lengkapi semua data akun terlebih dahulu.", + full_name=full_name, + username=username, + ) + ) + if len(username) < 3: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Username minimal 3 karakter.", + full_name=full_name, + username=username, + ) + ) + if len(password) < 6: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Password minimal 6 karakter.", + full_name=full_name, + username=username, + ) + ) + if password != confirm_password: + return HTMLResponse( + _auth_page( + mode="register", + error_message="Konfirmasi password belum sama.", + full_name=full_name, + username=username, + ) + ) + + try: + create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + error_text = "Username sudah terdaftar." + else: + error_text = "Registrasi gagal. Coba lagi." + return HTMLResponse( + _auth_page( + mode="register", + error_message=error_text, + full_name=full_name, + username=username, + ) + ) + + return HTMLResponse( + _auth_page( + mode="login", + success_message="Registrasi berhasil. Silakan login.", + username=username, + ) + ) + + +@app.get("/predictions") +def read_predictions(limit: int = 500): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit), + } + + +@app.post("/admin/predictions") +def create_prediction_from_admin( + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + save_prediction_result( + student_name, + _result_payload( + predicted_label=predicted_label, + student_gender=student_gender, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + return _redirect_admin() + + +@app.get("/admin/predictions/new", response_class=HTMLResponse) +def read_new_prediction_page(): + return _html_page( + title="Tambah Data", + active="prediction-new", + content=f""" +
+
+

Tambah Data

+

Tambahkan hasil analisis secara manual jika diperlukan.

+
+
+
+ {_prediction_form(action="/admin/predictions", submit_label="Tambah Data")} +
+ """, + ) + + +@app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) +def read_edit_prediction_page(prediction_id: int): + row = get_prediction_result(prediction_id) + if row is None: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _html_page( + title=f"Edit Data #{prediction_id}", + active="dashboard", + content=f""" +
+
+
+

Edit Data #{prediction_id}

+

Ubah hasil analisis yang tersimpan di admin.

+
+ Kembali +
+ {_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")} +
+ """, + ) + + +@app.post("/admin/predictions/{prediction_id}/edit") +def update_prediction_from_admin( + prediction_id: int, + student_name: str = Form(...), + student_gender: str = Form(""), + predicted_label: str = Form("PD"), + description: str = Form(""), + confidence_percent: float = Form(0), + probability_pd_percent: float = Form(0), + probability_tpd_percent: float = Form(0), + is_valid_audio: str = Form("1"), + error_message: str = Form(""), + audio_duration: float = Form(0), +): + student_name = student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + updated = update_prediction_result( + prediction_id, + _db_values( + student_name=student_name, + student_gender=student_gender, + predicted_label=predicted_label, + description=description, + confidence_percent=confidence_percent, + probability_pd_percent=probability_pd_percent, + probability_tpd_percent=probability_tpd_percent, + is_valid_audio=is_valid_audio, + error_message=error_message, + audio_duration=audio_duration, + ), + ) + if not updated: + raise HTTPException(status_code=404, detail="Data tidak ditemukan.") + + return _redirect_admin() + + +@app.post("/admin/predictions/{prediction_id}/delete") +def delete_prediction_from_admin(prediction_id: int): + delete_prediction_result(prediction_id) + return _redirect_admin() + + +@app.get("/admin/users", response_class=HTMLResponse) +def read_users_page(): + users = list_users(limit=500) + return _html_page( + title="Tambah User", + active="users", + content=f""" +
+
+

Tambah User

+

Kelola akun guru atau pengguna aplikasi ConfiVoice.

+
+
+
+
+
+

Tambah User

+

Username akan disimpan huruf kecil, password disimpan sebagai hash.

+
+
+ {_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)} +
+
+
+
+

CRUD User

+

Edit atau hapus user yang sudah terdaftar.

+
+
+ {_user_table(users)} +
+ """, + ) + + +@app.post("/admin/users") +def create_user_from_admin( + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(...), +): + full_name = full_name.strip() + username = username.strip() + password = password.strip() + if not full_name or not username or not password: + raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") + + try: + create_user(full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) +def read_edit_user_page(user_id: int): + user = get_user_by_id(user_id) + if user is None: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + + return _html_page( + title=f"Edit User #{user_id}", + active="users", + content=f""" +
+
+
+

Edit User #{user_id}

+

Kosongkan password jika tidak ingin mengganti password.

+
+ Kembali +
+ {_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")} +
+ """, + ) + + +@app.post("/admin/users/{user_id}/edit") +def update_user_from_admin( + user_id: int, + full_name: str = Form(...), + username: str = Form(...), + password: str = Form(""), +): + full_name = full_name.strip() + username = username.strip() + if not full_name or not username: + raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") + + try: + updated = update_user(user_id, full_name, username, password) + except Exception as error: + raise HTTPException( + status_code=400, + detail="Username sudah dipakai atau data user tidak valid.", + ) from error + if not updated: + raise HTTPException(status_code=404, detail="User tidak ditemukan.") + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.post("/admin/users/{user_id}/delete") +def delete_user_from_admin(user_id: int): + delete_user(user_id) + return RedirectResponse(url="/admin/users", status_code=303) + + +@app.get("/admin/logout", response_class=HTMLResponse) +def read_logout_page(): + response = RedirectResponse(url="/admin/login", status_code=303) + response.delete_cookie(ADMIN_SESSION_COOKIE) + return response + + +@app.get("/admin/students/{student_name}", response_class=HTMLResponse) +def read_student_predictions_page(student_name: str): + decoded_name = unquote(student_name) + rows = [ + row + for row in list_prediction_results(limit=500) + if row["student_name"] == decoded_name + ] + total = len(rows) + pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(rows) + + return _html_page( + title=f"Analisis {decoded_name}", + active="dashboard", + content=f""" +
+
+

{escape(decoded_name)}

+

Folder hasil analisis siswa/i.

+
+ Kembali +
+
+
Total Prediksi{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata PD{avg_pd * 100:.1f}%
+
+
+
+
+

Isi Folder Analisis

+

Edit atau hapus hasil prediksi milik siswa/i ini.

+
+
+ {_prediction_table(rows)} +
+ """, + ) + + +@app.get("/admin", response_class=HTMLResponse) +def read_admin_page( + q: str = Query("", alias="q"), + label: str = Query("", alias="label"), +): + rows = list_prediction_results(limit=500) + clean_query = q.strip() + clean_label = label.strip().upper() + if clean_label not in {"PD", "TPD"}: + clean_label = "" + filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) + total = len(filtered_rows) + pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") + tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") + avg_pd = _average_pd_ratio(filtered_rows) + grouped_rows = _group_predictions_by_student(filtered_rows) + + return _html_page( + title="Admin ConfiVoice", + active="dashboard", + content=f""" +
+
+

Admin ConfiVoice

+

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

+
+
+
+
Total Data Tampil{total}
+
Percaya Diri{pd_count}
+
Tidak Percaya Diri{tpd_count}
+
Rata-rata Percaya Diri{avg_pd * 100:.1f}%
+
+
+
+
+

Folder Siswa/i

+

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

+
+
+ {_dashboard_filter_form(clean_query, clean_label)} + {_student_folder_grid(grouped_rows)} +
+ """, + ) + + +def _redirect_admin(): + return RedirectResponse(url="/admin", status_code=303) + + +def _create_admin_session_token(user): + payload = { + "id": user["id"], + "username": user["username"], + "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, + } + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") + signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{encoded_payload}.{signature}" + + +def _verify_admin_session_token(token): + try: + encoded_payload, signature = token.split(".", 1) + expected_signature = hmac.new( + ADMIN_SESSION_SECRET.encode("utf-8"), + encoded_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected_signature): + return None + + payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) + if int(payload.get("exp") or 0) < int(time.time()): + return None + return payload + except Exception: + return None + + +def _get_admin_user_from_request(request): + token = request.cookies.get(ADMIN_SESSION_COOKIE) + if not token: + return None + payload = _verify_admin_session_token(token) + if not payload: + return None + return get_user_by_id(payload.get("id")) + + +def _password_eye_icon(): + return ( + '" + ) + + +def _password_eye_off_icon(): + return ( + '" + ) + + +def _result_payload( + *, + predicted_label, + student_gender, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return { + "predicted_label": predicted_label, + "student_gender": _clean_student_gender(student_gender), + "description": description, + "confidence": _percent_to_ratio(confidence_percent), + "probability_pd": _percent_to_ratio(probability_pd_percent), + "probability_tpd": _percent_to_ratio(probability_tpd_percent), + "is_valid_audio": is_valid_audio == "1", + "error_message": error_message.strip() or None, + "audio_quality": {"duration": audio_duration}, + "voice_indicators": {}, + } + + +def _db_values( + *, + student_name, + student_gender, + predicted_label, + description, + confidence_percent, + probability_pd_percent, + probability_tpd_percent, + is_valid_audio, + error_message, + audio_duration, +): + return ( + student_name, + _clean_student_gender(student_gender), + predicted_label.strip() or None, + description.strip() or None, + _percent_to_ratio(confidence_percent), + _percent_to_ratio(probability_pd_percent), + _percent_to_ratio(probability_tpd_percent), + 1 if is_valid_audio == "1" else 0, + error_message.strip() or None, + float(audio_duration or 0), + 0, + 0, + 0, + 0, + 0, + ) + + +def _percent_to_ratio(value): + return max(0, min(float(value or 0), 100)) / 100 + + +def _ratio_to_percent(value): + return f"{float(value or 0) * 100:.2f}" + + +def _clean_student_gender(value): + gender = (value or "").strip() + return gender if gender in {"Laki-laki", "Perempuan"} else None + + +def _student_gender_label(value): + return value or "Belum diisi" + + +def _pd_probability(row): + return float(row.get("probability_pd") or 0) + + +def _average_pd_ratio(rows): + return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 + + +def _filter_prediction_rows(rows, query, label): + query = query.strip().lower() + label = label.strip().upper() + + filtered_rows = rows + if query: + filtered_rows = [ + row + for row in filtered_rows + if query in (row["student_name"] or "").lower() + ] + if label in {"PD", "TPD"}: + filtered_rows = [ + row + for row in filtered_rows + if (row["predicted_label"] or "").upper() == label + ] + return filtered_rows + + +def _dashboard_filter_form(query, label): + all_selected = "selected" if not label else "" + pd_selected = "selected" if label == "PD" else "" + tpd_selected = "selected" if label == "TPD" else "" + return f""" +
+ + +
+ Reset +
+
+ """ + + +def _group_predictions_by_student(rows): + groups = {} + for row in rows: + student_name = row["student_name"] or "Tanpa Nama" + groups.setdefault(student_name, []).append(row) + return sorted(groups.items(), key=lambda item: item[0].lower()) + + +def _student_folder_grid(grouped_rows): + if not grouped_rows: + return '
Belum ada folder siswa/i.
' + + cards = [] + for student_name, rows in grouped_rows: + total = len(rows) + latest = rows[0] + gender = _student_gender_label(latest.get("student_gender")) + label = latest.get("predicted_label") or "-" + pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") + tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") + pd_sum = sum(_pd_probability(row) for row in rows) + labels = " ".join( + sorted({(row.get("predicted_label") or "").upper() for row in rows}) + ) + latest_pd = _pd_probability(latest) * 100 + pd_average = _average_pd_ratio(rows) * 100 + folder_url = f"/admin/students/{quote(student_name, safe='')}" + cards.append( + f""" + +
{escape(student_name[:1].upper() or "?")}
+
+ {escape(student_name)} + {escape(gender)} | {total} hasil analisis + Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}% +
+
+ """ + ) + + return ( + f'
{"".join(cards)}
' + '" + ) + + +def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): + row = row or {} + student_name = escape(str(row.get("student_name") or "")) + student_gender = str(row.get("student_gender") or "") + predicted_label = str(row.get("predicted_label") or "PD") + description = escape(str(row.get("description") or "")) + confidence = _ratio_to_percent(row.get("confidence")) + probability_pd = _ratio_to_percent(row.get("probability_pd")) + probability_tpd = _ratio_to_percent(row.get("probability_tpd")) + audio_duration = float(row.get("audio_duration") or 0) + error_message = escape(str(row.get("error_message") or "")) + valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" + pd_selected = "selected" if predicted_label == "PD" else "" + tpd_selected = "selected" if predicted_label == "TPD" else "" + male_selected = "selected" if student_gender == "Laki-laki" else "" + female_selected = "selected" if student_gender == "Perempuan" else "" + valid_selected = "selected" if valid_audio else "" + invalid_selected = "selected" if not valid_audio else "" + + return f""" +
+ + + + + + + + + + +
+ +
+
+ """ + + +def _prediction_table(rows): + table_rows = [] + for row in rows: + valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" + table_rows.append( + "" + f"{row['id']}" + f"{escape(str(row['created_at']))}" + f"{escape(row['student_name'])}" + f"{escape(_student_gender_label(row.get('student_gender')))}" + f"{escape(row['predicted_label'] or '-')}" + f"{escape(row['description'] or '-')}" + f"{float(row['confidence'] or 0) * 100:.2f}%" + f"{float(row['probability_pd'] or 0) * 100:.2f}%" + f"{float(row['probability_tpd'] or 0) * 100:.2f}%" + f"{float(row['audio_duration'] or 0):.2f} dtk" + f"{valid_text}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada hasil prediksi.' + ) + + return f""" +
+ + + + + + + + + + + + + + + + + + {body} +
IDWaktuNama siswa/iJenis kelaminLabelKeteranganConfidencePDTPDDurasiStatus audioAksi
+
+ """ + + +def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): + row = row or {} + full_name = escape(str(row.get("full_name") or "")) + username = escape(str(row.get("username") or "")) + required = "required" if password_required else "" + password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." + + return f""" +
+ + + +
+ +
+
+ """ + + +def _user_table(users): + table_rows = [] + for user in users: + table_rows.append( + "" + f"{user['id']}" + f"{escape(str(user['created_at']))}" + f"{escape(user['full_name'])}" + f"{escape(user['username'])}" + "" + f"Edit" + f"
" + "" + "
" + "" + "" + ) + + body = "\n".join(table_rows) or ( + 'Belum ada user terdaftar.' + ) + + return f""" +
+ + + + + + + + + + + {body} +
IDDibuatNama lengkapUsernameAksi
+
+ """ + + +def _nav_active(active, name): + return " active" if active == name else "" + + +def _sidebar(active): + update_open = " open" if active in {"prediction-new", "users"} else "" + return f""" + + """ + + +def _auth_page( + *, + mode, + error_message=None, + success_message=None, + full_name="", + username="", +): + is_register = mode == "register" + title = "Daftar" if is_register else "Masuk" + action = "/admin/register" if is_register else "/admin/login" + login_active = "" if is_register else " active" + register_active = " active" if is_register else "" + slider_class = " register" if is_register else "" + error_html = ( + f'
{escape(error_message)}
' + if error_message + else "" + ) + success_html = ( + f'
{escape(success_message)}
' + if success_message + else "" + ) + eye_icon = _password_eye_icon() + eye_off_icon = _password_eye_off_icon() + eye_icon_json = json.dumps(eye_icon) + eye_off_icon_json = json.dumps(eye_off_icon) + full_name_field = ( + f""" + + """ + if is_register + else "" + ) + confirm_password_field = ( + f""" + + """ + if is_register + else "" + ) + submit_label = "Daftar" if is_register else "Masuk" + + return f""" + + + + + + {title} ConfiVoice + + + +
+
+ CV + Admin Confivoice +
+
+ +

{'Buat akun Admin ConfiVoice.' if is_register else 'Masuk sebagai Admin ConfiVoice.'}

+ {error_html} + {success_html} +
+ {full_name_field} + + + {confirm_password_field} + +
+
+
+ + + + """ + + +def _html_page(title, content, active="dashboard"): + return f""" + + + + + + {escape(title)} + + + +
+ {_sidebar(active)} +
+ {content} +
+
+ + + + + + + """ diff --git a/cv_web/mysql.env.example b/cv_web/mysql.env.example new file mode 100644 index 0000000..da5f793 --- /dev/null +++ b/cv_web/mysql.env.example @@ -0,0 +1,6 @@ +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice diff --git a/cv_web/schema_mysql.sql b/cv_web/schema_mysql.sql new file mode 100644 index 0000000..d054781 --- /dev/null +++ b/cv_web/schema_mysql.sql @@ -0,0 +1,34 @@ +CREATE DATABASE IF NOT EXISTS confivoice + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE confivoice; + +CREATE TABLE IF NOT EXISTS prediction_results ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + student_name VARCHAR(255) NOT NULL, + student_gender VARCHAR(20), + predicted_label VARCHAR(20), + description VARCHAR(255), + confidence DOUBLE NOT NULL DEFAULT 0, + probability_pd DOUBLE NOT NULL DEFAULT 0, + probability_tpd DOUBLE NOT NULL DEFAULT 0, + is_valid_audio TINYINT(1) NOT NULL DEFAULT 1, + error_message TEXT, + audio_duration DOUBLE NOT NULL DEFAULT 0, + volume_score DOUBLE NOT NULL DEFAULT 0, + intonation_score DOUBLE NOT NULL DEFAULT 0, + pause_score DOUBLE NOT NULL DEFAULT 0, + speech_activity_ratio DOUBLE NOT NULL DEFAULT 0, + silence_ratio DOUBLE NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + full_name VARCHAR(255) NOT NULL, + username VARCHAR(120) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/docs/blackbox/Pengujian_Blackbox_ConfiVoice.html b/docs/blackbox/Pengujian_Blackbox_ConfiVoice.html new file mode 100644 index 0000000..4c8a982 --- /dev/null +++ b/docs/blackbox/Pengujian_Blackbox_ConfiVoice.html @@ -0,0 +1,387 @@ + + + + + Pengujian Blackbox ConfiVoice + + + +
+

Pengujian Blackbox User

+ + + + + + + + + + + + + + + + + +
Judul:Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara Menggunakan Pendekatan Machine Learning Berbasis Mobile
Nama:............................................................
Jabatan:User/Guru
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.HalamanSkenarioHasil yang DiharapkanKeterangan
BerhasilTidak Berhasil
1Halaman LoginPengguna membuka aplikasi ConfiVoice.Sistem menampilkan form login, tombol daftar akun, dan pengaturan endpoint API.✓ 
2Halaman DaftarPengguna memilih menu daftar dan mengisi nama lengkap, username, password, serta konfirmasi password.Sistem membuat akun baru dan mengarahkan pengguna kembali ke halaman login.✓ 
3Halaman LoginPengguna mengisi username dan password yang valid.Sistem berhasil masuk dan mengarahkan pengguna ke halaman utama analisis.✓ 
4Halaman UtamaPengguna mengisi nama siswa dan memilih jenis kelamin.Sistem menerima data siswa tanpa menampilkan error.✓ 
5Halaman UtamaPengguna memilih file audio dari perangkat.Sistem menampilkan audio yang dipilih dan menampilkan preview gelombang suara.✓ 
6Halaman UtamaPengguna menekan tombol rekam, berbicara, lalu menghentikan rekaman.Sistem menyimpan rekaman dalam format audio dan menampilkan preview hasil rekaman.✓ 
7Halaman PrediksiPengguna menekan tombol Prediksi setelah data siswa dan audio sudah lengkap.Sistem mengirim audio ke backend dan menampilkan hasil klasifikasi percaya diri atau tidak percaya diri beserta nilai persentase PD dan TPD.✓ 
8Halaman PrediksiPengguna menekan tombol Prediksi ketika nama, jenis kelamin, atau audio belum lengkap.Sistem menampilkan pesan peringatan agar pengguna melengkapi data terlebih dahulu.✓ 
9Halaman Hasil PrediksiPengguna menekan tombol Simpan Hasil setelah hasil prediksi muncul.Sistem menyimpan hasil analisis ke database dan mengosongkan form untuk pengujian siswa berikutnya.✓ 
10Halaman Lihat AnalisisPengguna membuka menu Lihat Analisis.Sistem menampilkan daftar riwayat hasil analisis yang sudah tersimpan berdasarkan data siswa.✓ 
11Halaman Detail AnalisisPengguna memilih salah satu data siswa pada riwayat analisis.Sistem menampilkan detail hasil prediksi, jenis kelamin, label prediksi, nilai PD, dan nilai TPD.✓ 
12LogoutPengguna menekan tombol logout.Sistem mengakhiri sesi pengguna dan kembali ke halaman login.✓ 
+ +
+
+

Pengujian Blackbox Web Admin

+ + + + + + + + + + + + + + + + + +
Judul:Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara Menggunakan Pendekatan Machine Learning Berbasis Mobile
Nama:............................................................
Jabatan:Admin
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No.HalamanSkenarioHasil yang DiharapkanKeterangan
BerhasilTidak Berhasil
1Halaman Login AdminAdmin membuka halaman login web admin.Sistem menampilkan form masuk admin dan pilihan daftar akun.✓ 
2Halaman Register AdminAdmin mengisi nama lengkap, username, password, dan konfirmasi password.Sistem membuat akun admin baru dan mengarahkan admin ke halaman login.✓ 
3Halaman Login AdminAdmin mengisi username dan password yang valid.Sistem berhasil masuk dan mengarahkan admin ke dashboard.✓ 
4Dashboard AdminAdmin membuka halaman dashboard.Sistem menampilkan statistik total prediksi, jumlah PD, jumlah TPD, rata-rata PD, serta tabel hasil analisis.✓ 
5Filter DashboardAdmin menggunakan pencarian atau filter label pada data prediksi.Sistem menampilkan data hasil analisis sesuai kata kunci atau label yang dipilih.✓ 
6Tambah Data PrediksiAdmin membuka halaman tambah data dan mengisi form hasil analisis secara manual.Sistem menyimpan data prediksi baru ke database.✓ 
7Edit Data PrediksiAdmin membuka halaman edit pada salah satu data prediksi dan mengubah isian data.Sistem memperbarui data prediksi pada database.✓ 
8Hapus Data PrediksiAdmin menekan tombol hapus pada salah satu data prediksi.Sistem menampilkan konfirmasi dan menghapus data setelah admin menyetujui.✓ 
9Detail SiswaAdmin membuka detail riwayat salah satu siswa.Sistem menampilkan kumpulan hasil analisis berdasarkan nama siswa yang dipilih.✓ 
10Kelola UserAdmin membuka halaman kelola user.Sistem menampilkan daftar user dan form untuk menambahkan user.✓ 
11Edit/Hapus UserAdmin mengubah atau menghapus data user.Sistem memperbarui atau menghapus data user dari database.✓ 
12Logout AdminAdmin memilih menu logout.Sistem menghapus sesi admin dan mengarahkan kembali ke halaman login admin.✓ 
+ +
+ + diff --git a/docs/blackbox/check/Pengujian_Blackbox_ConfiVoice.txt b/docs/blackbox/check/Pengujian_Blackbox_ConfiVoice.txt new file mode 100644 index 0000000..dad1e71 --- /dev/null +++ b/docs/blackbox/check/Pengujian_Blackbox_ConfiVoice.txt @@ -0,0 +1,176 @@ +Pengujian Blackbox User +Judul : Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara Menggunakan Pendekatan Machine Learning Berbasis Mobile +Nama : ............................................................ +Jabatan : User/Guru +No. +Halaman +Skenario +Hasil yang Diharapkan +Keterangan + + + + +Berhasil +Tidak Berhasil +1 +Halaman Login +Pengguna membuka aplikasi ConfiVoice. +Sistem menampilkan form login, tombol daftar akun, dan pengaturan endpoint API. +✓ + +2 +Halaman Daftar +Pengguna memilih menu daftar dan mengisi nama lengkap, username, password, serta konfirmasi password. +Sistem membuat akun baru dan mengarahkan pengguna kembali ke halaman login. +✓ + +3 +Halaman Login +Pengguna mengisi username dan password yang valid. +Sistem berhasil masuk dan mengarahkan pengguna ke halaman utama analisis. +✓ + +4 +Halaman Utama +Pengguna mengisi nama siswa dan memilih jenis kelamin. +Sistem menerima data siswa tanpa menampilkan error. +✓ + +5 +Halaman Utama +Pengguna memilih file audio dari perangkat. +Sistem menampilkan audio yang dipilih dan menampilkan preview gelombang suara. +✓ + +6 +Halaman Utama +Pengguna menekan tombol rekam, berbicara, lalu menghentikan rekaman. +Sistem menyimpan rekaman dalam format audio dan menampilkan preview hasil rekaman. +✓ + +7 +Halaman Prediksi +Pengguna menekan tombol Prediksi setelah data siswa dan audio sudah lengkap. +Sistem mengirim audio ke backend dan menampilkan hasil klasifikasi percaya diri atau tidak percaya diri beserta nilai persentase PD dan TPD. +✓ + +8 +Halaman Prediksi +Pengguna menekan tombol Prediksi ketika nama, jenis kelamin, atau audio belum lengkap. +Sistem menampilkan pesan peringatan agar pengguna melengkapi data terlebih dahulu. +✓ + +9 +Halaman Hasil Prediksi +Pengguna menekan tombol Simpan Hasil setelah hasil prediksi muncul. +Sistem menyimpan hasil analisis ke database dan mengosongkan form untuk pengujian siswa berikutnya. +✓ + +10 +Halaman Lihat Analisis +Pengguna membuka menu Lihat Analisis. +Sistem menampilkan daftar riwayat hasil analisis yang sudah tersimpan berdasarkan data siswa. +✓ + +11 +Halaman Detail Analisis +Pengguna memilih salah satu data siswa pada riwayat analisis. +Sistem menampilkan detail hasil prediksi, jenis kelamin, label prediksi, nilai PD, dan nilai TPD. +✓ + +12 +Logout +Pengguna menekan tombol logout. +Sistem mengakhiri sesi pengguna dan kembali ke halaman login. +✓ + + +Pengujian Blackbox Web Admin +Judul : Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara Menggunakan Pendekatan Machine Learning Berbasis Mobile +Nama : ............................................................ +Jabatan : Admin +No. +Halaman +Skenario +Hasil yang Diharapkan +Keterangan + + + + +Berhasil +Tidak Berhasil +1 +Halaman Login Admin +Admin membuka halaman login web admin. +Sistem menampilkan form masuk admin dan pilihan daftar akun. +✓ + +2 +Halaman Register Admin +Admin mengisi nama lengkap, username, password, dan konfirmasi password. +Sistem membuat akun admin baru dan mengarahkan admin ke halaman login. +✓ + +3 +Halaman Login Admin +Admin mengisi username dan password yang valid. +Sistem berhasil masuk dan mengarahkan admin ke dashboard. +✓ + +4 +Dashboard Admin +Admin membuka halaman dashboard. +Sistem menampilkan statistik total prediksi, jumlah PD, jumlah TPD, rata-rata PD, serta tabel hasil analisis. +✓ + +5 +Filter Dashboard +Admin menggunakan pencarian atau filter label pada data prediksi. +Sistem menampilkan data hasil analisis sesuai kata kunci atau label yang dipilih. +✓ + +6 +Tambah Data Prediksi +Admin membuka halaman tambah data dan mengisi form hasil analisis secara manual. +Sistem menyimpan data prediksi baru ke database. +✓ + +7 +Edit Data Prediksi +Admin membuka halaman edit pada salah satu data prediksi dan mengubah isian data. +Sistem memperbarui data prediksi pada database. +✓ + +8 +Hapus Data Prediksi +Admin menekan tombol hapus pada salah satu data prediksi. +Sistem menampilkan konfirmasi dan menghapus data setelah admin menyetujui. +✓ + +9 +Detail Siswa +Admin membuka detail riwayat salah satu siswa. +Sistem menampilkan kumpulan hasil analisis berdasarkan nama siswa yang dipilih. +✓ + +10 +Kelola User +Admin membuka halaman kelola user. +Sistem menampilkan daftar user dan form untuk menambahkan user. +✓ + +11 +Edit/Hapus User +Admin mengubah atau menghapus data user. +Sistem memperbarui atau menghapus data user dari database. +✓ + +12 +Logout Admin +Admin memilih menu logout. +Sistem menghapus sesi admin dan mengarahkan kembali ke halaman login admin. +✓ + + diff --git a/docs/diagrams/ERD(1M).drawio.png b/docs/diagrams/ERD(1M).drawio.png new file mode 100644 index 0000000..2520d86 Binary files /dev/null and b/docs/diagrams/ERD(1M).drawio.png differ diff --git a/docs/diagrams/ERD.drawio.png b/docs/diagrams/ERD.drawio.png new file mode 100644 index 0000000..828bf35 Binary files /dev/null and b/docs/diagrams/ERD.drawio.png differ diff --git a/docs/diagrams/Flowchart_app.drawio.png b/docs/diagrams/Flowchart_app.drawio.png new file mode 100644 index 0000000..8932042 Binary files /dev/null and b/docs/diagrams/Flowchart_app.drawio.png differ diff --git a/docs/diagrams/Flowchart_web_admin.drawio.png b/docs/diagrams/Flowchart_web_admin.drawio.png new file mode 100644 index 0000000..9faf929 Binary files /dev/null and b/docs/diagrams/Flowchart_web_admin.drawio.png differ diff --git a/docs/diagrams/Hyperplane SVM.JPEG b/docs/diagrams/Hyperplane SVM.JPEG new file mode 100644 index 0000000..0713dfa Binary files /dev/null and b/docs/diagrams/Hyperplane SVM.JPEG differ diff --git a/docs/diagrams/Pengenalan Suara.JPEG b/docs/diagrams/Pengenalan Suara.JPEG new file mode 100644 index 0000000..cc5ad46 Binary files /dev/null and b/docs/diagrams/Pengenalan Suara.JPEG differ diff --git a/docs/diagrams/Use_Case_cv.png b/docs/diagrams/Use_Case_cv.png new file mode 100644 index 0000000..129fa0e Binary files /dev/null and b/docs/diagrams/Use_Case_cv.png differ diff --git a/docs/gambar/Waterfall.drawio.png b/docs/gambar/Waterfall.drawio.png new file mode 100644 index 0000000..552fe80 Binary files /dev/null and b/docs/gambar/Waterfall.drawio.png differ diff --git a/docs/gambar/dfd_confivoice.png b/docs/gambar/dfd_confivoice.png new file mode 100644 index 0000000..4a61558 Binary files /dev/null and b/docs/gambar/dfd_confivoice.png differ diff --git a/docs/gambar/dfd_confivoice.svg b/docs/gambar/dfd_confivoice.svg new file mode 100644 index 0000000..ab5ac9a --- /dev/null +++ b/docs/gambar/dfd_confivoice.svg @@ -0,0 +1,6 @@ + + +Data Flow Diagram Level 1 Sistem ConfiVoice +Klasifikasi tingkat percaya diri berdasarkan analisis suara +Versi PNG berisi diagram lengkap dengan alur data mobile, API, model SVM, database, dan admin web. + diff --git a/docs/ringkasan_model_metode.md b/docs/ringkasan_model_metode.md new file mode 100644 index 0000000..ad5758f --- /dev/null +++ b/docs/ringkasan_model_metode.md @@ -0,0 +1,21 @@ +# Ringkasan Model dan Metode ConfiVoice + +ConfiVoice menggunakan pendekatan klasifikasi suara untuk membedakan tingkat kepercayaan diri pembicara ke dalam dua kelas, yaitu `PD` atau percaya diri dan `TPD` atau tidak percaya diri. Proses klasifikasi dilakukan dengan mengekstraksi ciri-ciri akustik dari file audio, lalu memasukkan vektor fitur tersebut ke model Support Vector Machine (SVM). Model dilatih menggunakan pipeline `StandardScaler` untuk standardisasi fitur dan `SVC` sebagai classifier. + +Audio diproses terlebih dahulu agar formatnya konsisten. Setiap audio dibaca sebagai sinyal mono dengan sample rate 22050 Hz, bagian silence di awal atau akhir dipotong, lalu volume dinormalisasi. Validasi dasar juga dilakukan untuk mendeteksi audio yang terlalu pendek, terlalu pelan, atau mengalami clipping karena volume terlalu keras. + +Fitur pertama yang digunakan adalah Mel-Frequency Cepstral Coefficients (MFCC). MFCC merepresentasikan karakteristik timbre atau warna suara berdasarkan persepsi pendengaran manusia. Pada sistem ini digunakan 13 koefisien MFCC, kemudian setiap koefisien diringkas menjadi nilai rata-rata dan standar deviasi. Dengan demikian, MFCC menghasilkan 26 fitur numerik. + +Selain MFCC statis, sistem juga menggunakan Delta MFCC. Delta MFCC menggambarkan perubahan MFCC antar frame audio sehingga dapat menangkap dinamika suara saat berbicara. Sama seperti MFCC, 13 nilai Delta MFCC diringkas menjadi rata-rata dan standar deviasi, sehingga menghasilkan 26 fitur tambahan. + +Fitur energi suara dihitung menggunakan RMS atau Root Mean Square Energy. Fitur ini menunjukkan kuat-lemahnya energi suara pembicara. Sistem menghitung RMS pada audio yang sudah dinormalisasi serta RMS pada audio mentah. Selain itu, sistem menghitung `peak_amplitude`, `clipping_ratio`, dan `energy_stability` untuk menilai kestabilan volume serta kemungkinan suara pecah akibat volume terlalu tinggi. + +Fitur pitch atau fundamental frequency digunakan untuk menangkap tinggi-rendah suara. Pitch diekstraksi menggunakan metode `pyin` dari library `librosa`. Dari pitch yang terdeteksi, sistem menghitung rata-rata pitch, standar deviasi pitch, rentang pitch, stabilitas pitch, dan variasi pitch. Fitur ini membantu membaca kestabilan serta variasi intonasi pembicara. + +Zero Crossing Rate (ZCR) digunakan untuk menghitung seberapa sering sinyal suara melewati titik nol. Nilai ini dapat menggambarkan karakter tekstur suara dan perubahan sinyal. Pada sistem ini, ZCR diringkas menjadi rata-rata dan standar deviasi. + +Sistem juga menggunakan fitur spektral, yaitu spectral centroid, spectral bandwidth, dan spectral rolloff. Spectral centroid menggambarkan pusat massa frekuensi dan berhubungan dengan kesan terang atau gelapnya suara. Spectral bandwidth menunjukkan lebar sebaran frekuensi, sedangkan spectral rolloff menunjukkan batas frekuensi tempat sebagian besar energi spektrum berada. Masing-masing fitur spektral diringkas menjadi nilai rata-rata dan standar deviasi. + +Selain fitur akustik, sistem menghitung fitur jeda bicara. Fitur ini meliputi durasi bicara aktif, durasi silence, rasio silence, jumlah jeda, rata-rata durasi jeda, dan rasio aktivitas bicara. Fitur jeda digunakan karena pola bicara yang terlalu banyak diam atau ragu-ragu dapat menjadi salah satu indikator rendahnya kepercayaan diri. + +Secara keseluruhan, sistem menghasilkan 78 fitur numerik dari setiap audio. Vektor fitur tersebut kemudian distandardisasi dan diklasifikasikan menggunakan model SVM. Pendekatan ini dipilih karena SVM cocok digunakan untuk data berukuran relatif kecil hingga menengah dan mampu membentuk batas keputusan yang baik pada ruang fitur berdimensi banyak. diff --git a/ml/.gitignore b/ml/.gitignore new file mode 100644 index 0000000..116f0ca --- /dev/null +++ b/ml/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +__pycache__/ +*.py[cod] +.venv/ +.venv_python312_failed/ diff --git a/ml/.ipynb_checkpoints/Untitled-checkpoint.ipynb b/ml/.ipynb_checkpoints/Untitled-checkpoint.ipynb new file mode 100644 index 0000000..363fcab --- /dev/null +++ b/ml/.ipynb_checkpoints/Untitled-checkpoint.ipynb @@ -0,0 +1,6 @@ +{ + "cells": [], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/.ipynb_checkpoints/app-checkpoint.py b/ml/.ipynb_checkpoints/app-checkpoint.py new file mode 100644 index 0000000..c95ad33 --- /dev/null +++ b/ml/.ipynb_checkpoints/app-checkpoint.py @@ -0,0 +1,312 @@ +from datetime import datetime +import tempfile +from pathlib import Path + +import joblib +import librosa +import numpy as np +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, SAMPLE_RATE, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" + +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +MIN_RECORDING_DURATION_SECONDS = 2.0 +LOW_RMS_WARNING_THRESHOLD = 0.003 + +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_bytes_to_temp_file(audio_bytes, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(audio_bytes) + return Path(temp_audio.name) + + +def save_uploaded_file(uploaded_file, suffix): + return save_bytes_to_temp_file(uploaded_file.getvalue(), suffix) + + +def validate_audio_quality(audio_path, min_duration=MIN_RECORDING_DURATION_SECONDS): + """ + Validasi dasar sebelum ekstraksi fitur. + Error dipakai untuk kasus file kosong/gagal dibaca/terlalu pendek. + Warning dipakai untuk audio yang masih bisa diproses tetapi kualitasnya lemah. + """ + try: + y, sr = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True) + except Exception as error: + raise ValueError(f"File audio gagal dibaca: {error}") from error + + if y.size == 0: + raise ValueError("File audio kosong atau tidak memiliki sinyal suara.") + + duration = librosa.get_duration(y=y, sr=sr) + if duration < min_duration: + raise ValueError( + f"Durasi audio terlalu pendek ({duration:.2f} detik). " + "Silakan rekam suara 3 sampai 5 detik dengan jelas." + ) + + rms = float(np.sqrt(np.mean(y**2))) + warning = None + if rms < LOW_RMS_WARNING_THRESHOLD: + warning = ( + f"Suara terdeteksi cukup pelan (RMS={rms:.5f}). " + "Jika hasil kurang tepat, rekam ulang dengan suara lebih jelas." + ) + + return { + "duration": duration, + "rms": rms, + "warning": warning, + } + + +def predict_audio_path(audio_path, validate_quality=True): + """ + Fungsi prediksi umum untuk upload dan rekaman. + + Urutan: + audio_path -> convert_to_wav -> validate -> extract_features -> predict_proba. + Label utama diambil dari probabilitas terbesar, bukan model.predict(). + """ + model = load_model(MODEL_PATH.stat().st_mtime) + audio_path = Path(audio_path) + extension = audio_path.suffix.lower() + + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(audio_path, temp_wav_path) + quality_info = validate_audio_quality(temp_wav_path) if validate_quality else None + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + "quality_info": quality_info, + } + finally: + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +def render_prediction_result(label, confidence, probabilities, debug_info): + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + quality_info = debug_info.get("quality_info") + if quality_info and quality_info.get("warning"): + st.warning(quality_info["warning"]) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + st.write(f"Margin: {margin * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") + if quality_info: + st.write(f"Durasi audio: {quality_info['duration']:.2f} detik") + st.write(f"RMS audio: {quality_info['rms']:.6f}") + + +def get_audio_recorder_input(): + """ + Menggunakan st.audio_input jika tersedia. + Jika belum tersedia, coba fallback ke streamlit-mic-recorder. + """ + if hasattr(st, "audio_input"): + return st.audio_input("Rekam suara") + + try: + from streamlit_mic_recorder import mic_recorder + except ImportError: + st.error( + "Versi Streamlit ini belum mendukung st.audio_input. " + "Install fallback recorder dengan perintah: pip install streamlit-mic-recorder" + ) + return None + + audio = mic_recorder( + start_prompt="Mulai Rekam", + stop_prompt="Berhenti Rekam", + just_once=False, + use_container_width=True, + key="mic_recorder", + ) + + if audio and audio.get("bytes"): + suffix = ".wav" + return { + "bytes": audio["bytes"], + "suffix": suffix, + "mime_type": "audio/wav", + } + + return None + + +def get_recording_bytes(recording): + if recording is None: + return None, ".wav", "audio/wav" + + if isinstance(recording, dict): + return recording["bytes"], recording.get("suffix", ".wav"), recording.get("mime_type", "audio/wav") + + suffix = Path(recording.name).suffix.lower() or ".wav" + return recording.getvalue(), suffix, recording.type or "audio/wav" + + +def save_recording_to_dataset(source_audio_path, label): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + target_dir = DATA_DIR / label + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / f"recorded_{label}_{timestamp}.wav" + + convert_to_wav(source_audio_path, target_path) + return target_path + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Input audio, ekstraksi fitur, prediksi SVM, lalu tampilkan PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +upload_tab, record_tab = st.tabs(["Upload Audio", "Rekam Audio"]) + +with upload_tab: + uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, + ) + + if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi Upload"): + temp_input_path = save_uploaded_file(uploaded_file, Path(uploaded_file.name).suffix.lower()) + try: + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + label, confidence, probabilities, debug_info = predict_audio_path(temp_input_path) + render_prediction_result(label, confidence, probabilities, debug_info) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + finally: + temp_input_path.unlink(missing_ok=True) + +with record_tab: + st.write( + "Silakan rekam suara selama 3-5 detik. Gunakan suara yang jelas, " + "tidak terlalu pelan, dan hindari noise ruangan." + ) + + recording = get_audio_recorder_input() + audio_bytes, suffix, mime_type = get_recording_bytes(recording) + + if audio_bytes: + st.audio(audio_bytes, format=mime_type) + + temp_recording_path = save_bytes_to_temp_file(audio_bytes, suffix) + st.session_state["latest_recording_path"] = str(temp_recording_path) + + if st.button("Prediksi Rekaman"): + try: + with st.spinner("Mengekstraksi fitur dan memprediksi rekaman..."): + label, confidence, probabilities, debug_info = predict_audio_path(temp_recording_path) + render_prediction_result(label, confidence, probabilities, debug_info) + except Exception as error: + st.error(f"Gagal memproses rekaman: {error}") + + st.divider() + st.subheader("Simpan Rekaman ke Dataset") + selected_label = st.selectbox( + "Label manual", + options=[LABEL_PD, LABEL_TPD], + format_func=lambda label: f"{label} - {LABEL_DESCRIPTION[label]}", + ) + + if st.button("Simpan ke Dataset"): + try: + saved_path = save_recording_to_dataset(temp_recording_path, selected_label) + st.success( + "Rekaman berhasil disimpan. Jalankan ulang train_model.py " + "untuk melatih ulang model." + ) + st.write(f"File: {saved_path}") + except Exception as error: + st.error(f"Gagal menyimpan rekaman ke dataset: {error}") diff --git a/ml/.ipynb_checkpoints/train_model-checkpoint.py b/ml/.ipynb_checkpoints/train_model-checkpoint.py new file mode 100644 index 0000000..68ebdfa --- /dev/null +++ b/ml/.ipynb_checkpoints/train_model-checkpoint.py @@ -0,0 +1,168 @@ +from collections import Counter +from pathlib import Path + +import joblib +from sklearn.metrics import ( + accuracy_score, + balanced_accuracy_score, + classification_report, + confusion_matrix, + f1_score, + precision_score, + recall_score, +) +from sklearn.model_selection import GridSearchCV, StratifiedKFold, cross_val_predict +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.svm import SVC + +from features import LABEL_PD, LABEL_TPD, check_dataset_quality, load_dataset + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +MODEL_DIR = BASE_DIR / "models" +MODEL_PATH = MODEL_DIR / "svm_voice_confidence_model.joblib" + + +def build_pipeline(): + """ + Pipeline wajib: + 1. StandardScaler + 2. SVM classifier + """ + return Pipeline( + [ + ("scaler", StandardScaler()), + ( + "svm", + SVC( + probability=True, + class_weight="balanced", + random_state=42, + ), + ), + ] + ) + + +def build_cv(y): + label_counts = Counter(y) + min_class_count = min(label_counts.values()) + n_splits = min(5, min_class_count) + + if n_splits < 2: + raise ValueError("Minimal perlu 2 data pada setiap kelas untuk Stratified K-Fold.") + + return StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) + + +def build_grid_search(cv): + param_grid = { + "svm__C": [0.1, 1, 10, 100], + "svm__gamma": ["scale", 0.01, 0.001, 0.0001], + "svm__kernel": ["rbf"], + } + + return GridSearchCV( + estimator=build_pipeline(), + param_grid=param_grid, + scoring="f1_macro", + cv=cv, + n_jobs=1, + refit=True, + verbose=1, + ) + + +def print_wrong_predictions(files, y_true, y_pred): + print("\n=== File yang Salah Prediksi ===") + has_wrong_prediction = False + + for file_path, true_label, predicted_label in zip(files, y_true, y_pred): + if true_label != predicted_label: + has_wrong_prediction = True + print(f"{Path(file_path).name} | {true_label} | {predicted_label}") + + if not has_wrong_prediction: + print("Tidak ada file yang salah prediksi pada cross-validation.") + + +def evaluate_model(model, X, y, files, cv): + """ + Evaluasi memakai prediksi out-of-fold agar lebih realistis untuk dataset kecil. + """ + y_pred = cross_val_predict(model, X, y, cv=cv, n_jobs=1) + labels = [LABEL_PD, LABEL_TPD] + + print("\n=== Evaluasi Cross-Validation ===") + print(f"Accuracy : {accuracy_score(y, y_pred):.4f}") + print(f"Balanced Accuracy : {balanced_accuracy_score(y, y_pred):.4f}") + print(f"Precision Macro : {precision_score(y, y_pred, average='macro', zero_division=0):.4f}") + print(f"Recall Macro : {recall_score(y, y_pred, average='macro', zero_division=0):.4f}") + print(f"F1 Macro : {f1_score(y, y_pred, average='macro', zero_division=0):.4f}") + + print("\n=== Classification Report ===") + print( + classification_report( + y, + y_pred, + labels=labels, + target_names=["PD - Percaya Diri", "TPD - Tidak Percaya Diri"], + zero_division=0, + ) + ) + + print("=== Confusion Matrix ===") + matrix = confusion_matrix(y, y_pred, labels=labels) + print("Urutan label:", labels) + print(matrix) + + print("\n=== Ringkasan Benar/Salah per Kelas ===") + for index, label in enumerate(labels): + total = int(matrix[index].sum()) + correct = int(matrix[index, index]) + wrong = total - correct + print(f"{label}: benar={correct}, salah={wrong}, total={total}") + + print_wrong_predictions(files, y, y_pred) + + +def main(): + print("Mengecek kualitas dataset...") + check_dataset_quality(DATA_DIR) + + print("\nMembaca dataset dan mengekstraksi fitur...") + X, y, files = load_dataset(DATA_DIR) + + print(f"\nTotal data valid: {len(files)}") + print(f"Jumlah fitur per audio: {X.shape[1]}") + print("Distribusi label:", dict(Counter(y))) + + cv = build_cv(y) + grid_search = build_grid_search(cv) + + print("\nMelakukan GridSearchCV SVM dengan scoring f1_macro...") + grid_search.fit(X, y) + + best_model = grid_search.best_estimator_ + + print("\n=== Hasil GridSearchCV ===") + print("Best params:", grid_search.best_params_) + print(f"Best CV f1_macro: {grid_search.best_score_:.4f}") + print("Urutan kelas model:", list(best_model.classes_)) + + evaluate_model(best_model, X, y, files, cv) + + print("\nMelatih ulang model terbaik dengan seluruh dataset...") + best_model.fit(X, y) + print("Urutan kelas model final:", list(best_model.classes_)) + + MODEL_DIR.mkdir(parents=True, exist_ok=True) + joblib.dump(best_model, MODEL_PATH) + + print(f"Model terbaik berhasil disimpan ke: {MODEL_PATH}") + + +if __name__ == "__main__": + main() diff --git a/ml/.python-version b/ml/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/ml/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/ml/.streamlit/config.toml b/ml/.streamlit/config.toml new file mode 100644 index 0000000..e7f9632 --- /dev/null +++ b/ml/.streamlit/config.toml @@ -0,0 +1,5 @@ +[browser] +gatherUsageStats = false + +[server] +headless = true diff --git a/ml/README.md b/ml/README.md new file mode 100644 index 0000000..df3737e --- /dev/null +++ b/ml/README.md @@ -0,0 +1,86 @@ +# Project SVM Suara + +Project ini mengklasifikasikan tingkat percaya diri dari file audio `.wav` +menggunakan ekstraksi fitur suara dengan `librosa` dan model SVM. + +Folder `ml/` sekarang juga menjadi lokasi API prediksi untuk Flutter. + +Gunakan Python 3.10 agar dependency audio seperti `librosa`, `numba`, dan +`llvmlite` lebih mudah terpasang. + +## Struktur + +```text +project_svm_suara/ +├── data/ +├── models/ +├── features.py +├── audio_utils.py +├── train_model.py +├── predict.py +├── app.py +└── requirements.txt +``` + +## Format Dataset + +Letakkan file `.wav` di folder `data/`. + +- Nama file mengandung `_pd` untuk kelas `PD` atau Percaya Diri. +- Nama file mengandung `_tpd` untuk kelas `TPD` atau Tidak Percaya Diri. + +Contoh: + +```text +a_pd.wav +a_tpd.wav +``` + +## Cara Menjalankan + +```bash +python3.10 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python train_model.py +python predict.py data/a_pd.wav +streamlit run app.py +``` + +## Menjalankan API untuk Flutter + +```bash +cd ml +../cv_app/.venv/bin/python -m uvicorn api_app:app --host 0.0.0.0 --port 8000 +``` + +Endpoint: + +```text +POST /predict +GET /predictions +GET /model +``` + +Hasil prediksi disimpan ke MySQL/MariaDB agar bisa dilihat lewat phpMyAdmin: + +```text +database: confivoice +table: prediction_results +``` + +Jika MySQL memakai port/user/password berbeda, atur environment sebelum +menjalankan API: + +```bash +export CONFIVOICE_DB_DRIVER=mysql +export CONFIVOICE_MYSQL_HOST=127.0.0.1 +export CONFIVOICE_MYSQL_PORT=3306 +export CONFIVOICE_MYSQL_USER=root +export CONFIVOICE_MYSQL_PASSWORD= +export CONFIVOICE_MYSQL_DATABASE=confivoice +``` + +Aplikasi Streamlit menerima upload audio `WAV`, `MP3`, `M4A`, `OGG`, `FLAC`, +`WEBM`, dan `AAC`. File akan disiapkan menjadi WAV mono 22050 Hz sebelum fitur +suara diekstraksi. diff --git a/ml/Untitled.ipynb b/ml/Untitled.ipynb new file mode 100644 index 0000000..b2054f5 --- /dev/null +++ b/ml/Untitled.ipynb @@ -0,0 +1,866 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 5, + "id": "8354ba29-0c6f-41d8-86ab-76f296355a6a", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/user/TA/confivoice3/ml/features.py:70: UserWarning: PySoundFile failed. Trying audioread instead.\n", + " y, sr = librosa.load(file_path, sr=sample_rate, mono=True)\n", + "/Users/user/TA/confivoice3/cv_app/.venv/lib/python3.10/site-packages/librosa/core/audio.py:184: FutureWarning: librosa.core.audio.__audioread_load\n", + "\tDeprecated as of librosa version 0.10.0.\n", + "\tIt will be removed in librosa version 1.0.\n", + " y, sr_native = __audioread_load(path, offset, duration, dtype)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Distribusi Label Dataset ===\n", + "PD : 43\n", + "TPD: 43\n", + "Total data: 86\n", + "Distribusi label: {'PD': 43, 'TPD': 43}\n", + "Jumlah fitur: 78\n", + "Fitting 5 folds for each of 16 candidates, totalling 80 fits\n", + "Best params: {'svm__C': 10, 'svm__gamma': 'scale', 'svm__kernel': 'rbf'}\n", + "Best CV f1_macro: 0.6554293822792274\n", + "Urutan kelas: ['PD', 'TPD']\n", + "Urutan label: ['PD', 'TPD']\n", + "[[29 14]\n", + " [15 28]]\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjUAAAHZCAYAAAB+e8r8AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAARdxJREFUeJzt3QmcTXX/wPHvGctgmLFlH0uRPVtSdilbj6yPHlREuyUU6SlFmx4Rzx/x9FQk2VKWKFFiCBWRFFNkDY8ihmHGMvN/fX899z4zY8bcO86de+ecz9vrvMY998y55557557v/X6/v3Os5OTkZAEAAMjhwoK9AQAAAHYgqAEAAI5AUAMAAByBoAYAADgCQQ0AAHAEghoAAOAIBDUAAMARCGoAAIAjENQAAABHIKgB/PTzzz9LmzZtJCoqSizLksWLF9u6D/ft22fWO3PmTF6b/2rZsqWZcrq+fftKxYoVXb0PgEAiqEGOtGfPHnnooYfk2muvlXz58klkZKQ0adJE/vnPf8q5c+cC+th9+vSR77//Xl566SV599135cYbbxSn0IOuBlS6P9PbjxrQ6f06jR8/3u/1Hz58WEaPHi3btm2TYPI8h/vvvz/d+59++mnvMr///rvkJOfPnzd/B/Xq1TOvY+HChaVmzZry4IMPyq5du8wyd955pxQoUEBOnz6d4Xp69+4tefPmlePHjzt+n8E5CGqQ4yxfvlxq164tCxYskI4dO8rkyZNl7NixUr58eRk+fLg89thjAXtsPdBv3LhR+vfvLwMHDpS7775bypUrZ+tjVKhQwTzOPffcI8GQO3duOXv2rHz00UeX3ffee++ZIDKrNKgZM2aM30HNypUrzWQnfR4ffPCBCQLSmjt37lU9z2Dq1q2bPP7441KrVi155ZVXzP5u3ry5fPLJJ7Jp0yZvwKLvsUWLFqW7Dn39lyxZIu3atZNixYo5fp/BOQhqkKPs3btX/va3v5kD/48//mi+kT7wwAMyYMAA86Gq8/RbaaD89ttv5qd++w0U/aarB4dcuXJJMISHh0vr1q3N/kxrzpw5cscdd2TbtujBVWnGQCc76QE7Li7OHOxT2rBhg3mfZefztMs333wjy5Ytk+eff95kER999FET5E+bNs2UNfVLgCdTU6hQIfN6pkcDmvj4eBP8OH2fwVkIapCjjBs3Ts6cOSNvvfWWlC5d+rL7K1eunCpTc/HiRXnhhRfkuuuuMwdr7Wf4+9//LomJial+T+f/5S9/kfXr18tNN91kggotbc2aNcu7jJZNNJhSmhHS4MPTH5FRr4T+ji6X0qpVq6Rp06YmMCpYsKBUrVrVbFNmPTWrV6+WZs2aSUREhPndTp06yc6dO9N9vN27d5tt0uW09+e+++7zBgi+6NWrlzlwnTx5MtUBU8tPel9aJ06ckCeeeMJk0PQ5admjffv28t1333mXWbNmjTRs2ND8X7fHU6rwPE/tF9HswpYtW0xmQcsjnv2Stp9ES4D6GqV9/m3btpUiRYqYjFBmypYtax4n7YFds1H6PHRb0vP+++9LgwYNJH/+/FK8eHGTrfv1118vW057rXQdup36M6OsSFJSkkyaNMkE47psyZIlTWn1jz/+kKyUZZWWYtPSINmTddFt79q1q3z++edy7Nixy5bVfaJBjwY/duwzILsQ1CBH0ZKIBhuNGzf2aXmt/z/77LNSv359mThxorRo0cKUqjTbk5YGAt27d5fbb79dJkyYYA6OGhj88MMP5n49COg6VM+ePc03YT0Y+UPXpcGTBlX6bVofRw8cX3755RV/77PPPjMHbD0AaeAybNgw8+1YD14aBKXVo0cP0y+hz1X/r4GDliF8pc9VA44PP/zQO08PZNWqVTP7Mq1ffvnFHMT1ub322msm6NO+I93fngCjevXq5jkr7e/Q/aeTHiQ9tH9Dg6G6deuafduqVat0t08zdNdcc40Jbi5dumTm/etf/zIlKi1HlilTxqfnqQGavqc0UPYEwRq0pBe4Kd2Puj81QNB9q1lC3UcapKYMAHU7tAyk+1CX69y5swnkNm/efNk6NYDR/eXpCdPlNEjQ1/vChQviD0/Qrb+vz+VKNAujy2gZN22A+umnn0qXLl1M8HO1+wzIVslADnHq1Klkfct26tTJp+W3bdtmlr///vtTzX/iiSfM/NWrV3vnVahQwcyLiYnxzjt27FhyeHh48uOPP+6dt3fvXrPcq6++mmqdffr0MetI67nnnjPLe0ycONHc/u233zLcbs9jzJgxwzuvbt26ySVKlEg+fvy4d953332XHBYWlnzvvfde9nj9+vVLtc4uXbokFytWLMPHTPk8IiIizP+7d++e3Lp1a/P/S5cuJZcqVSp5zJgx6e6DhIQEs0za56H77/nnn/fO++abby57bh4tWrQw902fPj3d+3RK6dNPPzXLv/jii8m//PJLcsGCBZM7d+6c7Av9vQEDBiSfOHEiOW/evMnvvvuumb98+fJky7KS9+3b592Xntfq/Pnz5jWoVatW8rlz57zrWrZsmVnu2WefTfV6lS5dOvnkyZPeeStXrjTLpXyfrFu3zsx77733Um3fihUrLpuf3j5IKykpybsfS5YsmdyzZ8/kqVOnJu/fv/+yZS9evGi28ZZbbkk1X/e//r7u36vdZwgd586dM5+hdk4p/w5CBZka5Bhay1eaFvfFxx9/bH5qViMlbaL0NBynVKNGDVPe8dBMgJaGNAthF08vjvYsaNnBF0eOHDGNtZo1Klq0qHf+DTfcYLJKnueZ0sMPP5zqtj4vzYJ49qEv9Ju3loyOHj1qSl/6M6Nv41raCwv78+NEMyf6WJ7S2rfffuvzY+p6NFPhCx1Wr1kOzf5oZklLN5qt8Ydm47RPxNM/pNkozQJ6Mh4paZZFM2Xap5KyIVb7SDSD5Xk/eV4vzSJp6c9DXyt9j6WkGQ5dRu/TEUOeSctbuv+++OILv56PZoY0y/Liiy+a56bPS/vN9PncddddqbJJmm3SjKU2vqfM9uk+0BKY9lVd7T5DaEhISJD8hYqZ95qdU6VKlcy6Q0nuYG8A4Cvt01BXGoaa0v79+82BVvtsUipVqpQJLvT+lHT0VHof4FnpbciIHljefPNNUxYbOXKkOXDoAVnLXp6gIL3noTRASEtLOnoQ06ZO7bXJ6Lno81D6XDz7MTMdOnQwAeT8+fPNQVr7YXRfplfu0gBNSyevv/66aRj1lIRUytEzmdGeDX8agnVYuQaIun16cC1RooT4SwM1HWl24MABU0LTvi1/XwcNarQfK+VyVapUuWy5tEGe9iidOnUqw+1Or9/Fl8BQh1frpAHW2rVrzWujZaY8efLI7NmzU5WgtKSq+077lw4dOiTr1q2TwYMHX7FR3dd9htBwXkerXTwr4TX6iOSyqeH+0nk5+uM7Zt2hNOqNoAY5hh6MtVdix44dfv1e2kbdjGT0If5n5j1rj5Hy4K60RyEmJsZ8A9dv9itWrDBBw6233mr6MOwa8XQ1zyXlwVEDrnfeecdkq7SXJyMvv/yyjBo1Svr162caszWjpEHakCFDfM5IqfR6OK5k69at3gO/9vBor5O/tKdJn6tmVrTXSXtmsovuGw1otAcmPZotvBraTK/ZGO3v0UZkDWy0L0iH7SvNCGlAplkXDWr0p75H0o56CqV9hquQO59YNgU1yVZoFnpCc6uADGgjqo7w0JR5ZjQdrgcN/Tac0n/+8x+ThrczXa6ZkJSpfY+02SClB3vN0GhDrQ5B15P4aXkno1KDZztjY2Mvu09PpqYjcFJmaeyk38g1cNDsWHrN1R4LFy40Tb06Kk2X09LQbbfddtk+8TXA9IVmp7RUpSUdbTzWbIGO0PKXBlLayKulNi0D6f7093XQeZ77PT/Tvu/S+10dlaelOm0S1v2VdqpTp47YQTM0Wq7UxuO0J8bTAEa/KGzfvt1kbDTD5BmldrX7DMhuBDXIUUaMGGEO4Fq+0eAkLQ14NNXuKZ+otCOUNJhQdp5TQw9OWkbQA4OHpv7TDuPVkSVp6UgflXaYecpv27qMZkxSBgl6INLsjud5BoIGKpp5mTJliinbXSkzlDYLpP0iaYc6e4Kv9AJAfz355JOm/KH7RV9THVLvyRz4S4ejP/fccybblBE9c7RmVaZPn57qMXTouw4t97yfUr5e+p5IOZRfg9iUNMOh2Tzdx2npqCJ/95MGUrpP0tL16BcBDb7TZn88WRkdJahlvMyyNP7sM4QYy3yzsGmSkET5CTmKBg/6bVJ7U7Sf5N577zXnxtC6rg5x1gOpNtQq/ZarB7k33njDfKjr8OKvv/7aHGz0W2ZGw4WzQrMTepDVYbDaj6DnhNETnl1//fWpeii0qVXLT3oA1G/0WjrRPhQ9K7EOC87Iq6++aoY633LLLeZsxno2WB26rM16VyoLXS3NKj3zzDM+ZdD0uWnmRJtGtRSkJRUdfp/29dN+Jg0MtF9Hg5xGjRqZhkN/aGZL95seVD1DzGfMmGHOZaMHWX97PPS9kllWRLMd//jHP8xz1PeSlro0sNYgWgOqoUOHepfVYdz6GutrqiU5DWb19dISkGcotNL1aLOzLq8BhWa49HE0ONH3sq5b+618pecF0uyavle0OVzLgBpY6nteh9ZrgJ+2NKn7Xl8z7U1SvgY1vuwzhBgr7M/JrnWFoNDcKiCTer5mRPTDXj+IdXSHNt1qA6ue9+X//u//vMtqU66en0XLEtrfoQfDp556SubNm2frPtZmWM3K6AnjNJukBxE9UHnO4Jpy27WJ9+233zbbPXXqVHOeFt2ulCNl0tJShPbf6OPoN2ptkL355pvN+W38DQgCQfsxdFSZNi3ryQ81kNOeoejo6FTL6QFb940eWHWElgYG2sjqDy2FaaCg1zbSZlgPPYjrY+t7wHM5ALtpwKw9UBpEaxCro600kNUm4ZRnmdbRQRqUaBZG3296LhsNutK7TpgGeBp4a4Cr+1GX1/eDntQvvZPoXYm+lzTroxkizV5pwKSNwBpAa4kwo0uIeAIZPfFk2sZ6ICexdFx3sDcCAAAERlxcnPnSFF7vUbFyhduyzuRLiZK49XUTQPs6ojI7UH4CAMANLMpPAAAAOQKZGgAA3MD678glu9YVgmgUBgAAjkCmBgAAVwizcSh2aOZECGoAAHADy/nlJ4KaINJT+OsJsfQkZHaePh4AkHPomVX0/Et6bbuMLmwL3xDUBJEGNGlPTgYAcKeDBw+as4sHjOX8Id0ENUGkGRqVt0Yf266cCuREB9aMD/YmAEFzOi5OKleK9h4TkHUENUHkKTlpQENQAzcLpTOSAsES8DYEi54aAADgBJbzy0+huVUAAAB+ovwEAIAbWJSfAACAE1iUnwAAAHIEyk8AALim/BRm37pCEI3CAAAgoMaOHSsNGzY05+IpUaKEdO7cWWJjY1Mtc/ToUbnnnnukVKlSEhERIfXr15cPPvjAr8chqAEAwA3CLHsnP6xdu1YGDBggmzZtklWrVsmFCxekTZs2Eh8f713m3nvvNYHO0qVL5fvvv5euXbtKjx49ZOvWrT4/DuUnAADcwApeo/CKFStS3Z45c6bJ2GzZskWaN29u5m3YsEGmTZsmN910k7n9zDPPyMSJE80y9erV8+lxyNQAAIAsiYuLSzUlJib69HunTp0yP4sWLeqd17hxY5k/f76cOHHCXPB53rx5kpCQIC1btvR5ewhqAABw03lqLJsmEXNR5qioKO+kvTOZ0YBlyJAh0qRJE6lVq5Z3/oIFC0xZqlixYhIeHi4PPfSQLFq0SCpXruzzU6T8BAAAsnxl8ZTXbtNgJDPaW7Njxw5Zv359qvmjRo2SkydPymeffSbFixeXxYsXm56adevWSe3atX3aHoIaAADcwLK/p0YDGn8uSDtw4EBZtmyZxMTESLly5bzz9+zZI1OmTDHBTs2aNc28OnXqmIBm6tSpMn36dJ/WT1ADAIAbWMG7TEJycrIMGjTIlJPWrFkjlSpVSnX/2bNnzc+wsNRBV65cuUy5ylcENQAAIKC05DRnzhxZsmSJOVeNnpNGaR9O/vz5pVq1aqZ3Rvtoxo8fb/pqtPykw781s+MrGoUBAHBT+cmyafKDDtXWEU86kql06dLeSUc7qTx58sjHH38s11xzjXTs2FFuuOEGmTVrlrzzzjvSoUMHnx+HTA0AAAgoLT9lpkqVKn6fQTgtghoAANzACl5PTXYhqAEAwA2s4J1ROLuE5lYBAAD4iUwNAABuYFF+AgAAjhBmY9koNAs9oblVAAAAfqL8BACAG1jOLz+RqQEAAI5ApgYAANdkasLsW1cIIqgBAMANLM5TAwAAkCOQqQEAwA0sGoUBAAByBDI1AAC4geX8nhqCGgAA3MCi/AQAAJAjkKkBAMANLMpPAADACSzKTwAAADkC5ScAAFzAsiwz2bQyCUWhOSYLAADAT2RqAABwAcsFmRqCGgAA3MD672TXukIQ5ScAAOAIZGoAAHABywXlJzI1AADAEcjUAADgApYLMjUENQAAuIDlgqCG8hMAAHAEMjUAALiARaYGAAAgZyBTAwCAG1jOP/keQQ0AAC5gUX4CAADIGcjUAADgAjoK274h3RKSCGoAAHABS//Zdn6Z0IxqOE8NAABwBDI1AAC4gEWjMAAAQM5ApgYAADewOE8NAABwAsu+RuFkLmgJAAAQOJSfAABwAcvGTI19Q8PtxZBuAADgCAQ1AAC4KFNj2TT5Y+zYsdKwYUMpVKiQlChRQjp37iyxsbGXLbdx40a59dZbJSIiQiIjI6V58+Zy7tw5nx+HoAYAADeNfrJsmvywdu1aGTBggGzatElWrVolFy5ckDZt2kh8fHyqgKZdu3Zm/tdffy3ffPONDBw4UMLCfA9V6KkBAAABtWLFilS3Z86caTI2W7ZsMdkYNXToUBk8eLCMHDnSu1zVqlX9ehwyNQAAuIAVxPJTWqdOnTI/ixYtan4eO3ZMvvrqKxPoNG7cWEqWLCktWrSQ9evX+7VeghoAAFzACkBQExcXl2pKTEzMdDuSkpJkyJAh0qRJE6lVq5aZ98svv5ifo0ePlgceeMBkdurXry+tW7eWn3/+2efnSFADAACyJDo6WqKioryTNgRnRntrduzYIfPmzUsV6KiHHnpI7rvvPqlXr55MnDjRlJ/efvttn7eHnhoAAFzACsB5ag4ePGhGKXmEh4df8fe08XfZsmUSExMj5cqV884vXbq0+VmjRo1Uy1evXl0OHDjg83aRqQEAAFmiAU3KKaOgJjk52QQ0ixYtktWrV0ulSpVS3V+xYkUpU6bMZcO8f/rpJ6lQoYLP20OmBgAAF7CCeEZhLTnNmTNHlixZYs5Vc/ToUTNfS1b58+c36xs+fLg899xzUqdOHalbt6688847smvXLlm4cKHPj0NQAwCAG1jBu0r3tGnTzM+WLVummj9jxgzp27ev+b82DyckJJih3SdOnDDBjZ7T5rrrrvP5cQhqAABAQGn5yRd6jpqU56nxF0ENAAAuYHFBSwAAgJyBTA0AAC5guSBTQ1ADAIALWC4IajhPDQAAcAQyNQAAuIEVvCHd2YWgBgAAF7AoPwEAAOQMZGoAAHABMjUAAAA5BJkaAABcwBIbh3SHaKcwQQ0AAC5g0SgMAACQM5CpAQDADSznn6eGMwoDAABHIFMDAIALWC7oqSGogeMN7dtG/tKqjlSpUFISEi/I19t/kdFTlsju/ce8y1QsW1xeeKyL3Fz3WsmbJ7d8vnGnPDn+ffntxOmgbjtgly+/3S2T3/1Mvtt1QI7+HiezX31A7mhZJ91lh46dKzM//FJeHtpNHunVihfBISwXBDWOLj/t27fP7Pht27aZ22vWrDG3T548GexNQzZqXL+yvPl+jLTpN166DpwieXLnkg8nD5QC+fKa+/Xnh1MGSLIkS6dHJkv7+ydK3jy5ZO5rD4XsHy7gr7PnEqXW9WXl1RF3XXG5ZV98J5u/3yelr4liJyPHyRFBzcaNGyVXrlxyxx13XNV6GjduLEeOHJGoqIz/WCtWrOiNZiMiIqR+/fry/vvve+8fPXq09/7cuXNL8eLFpXnz5jJp0iRJTEy8qu1DYPx18Osyd9lXsuuXo7Lj51/l0TGzJbp0UalbPdrc36jOtVK+dDEZMGa2/LjnsJkeHf2u1KteXpo3vJ6XBY5we5Oa8swjHU3WMiOHj500Gco3XugruXPnytbtQ+BZlr1TKMoRQc1bb70lgwYNkpiYGDl8+HCW15M3b14pVapUpt++n3/+eRP8bN26VRo2bCh33XWXbNiwwXt/zZo1zf0HDhyQL774Qv7617/K2LFjTdB0+jTlilAXWTCf+flH3FnzMzxvbklOTpbE8xe9yyScvyhJSclyc53rgradQHZKSkqSh5+bJYPubi3VryvNzkeOFPJBzZkzZ2T+/PnyyCOPmEzNzJkzU93/xx9/SO/eveWaa66R/PnzS5UqVWTGjBnprsvX8lOhQoVM8HP99dfL1KlTzXo/+ugj7/2aodH7y5QpI7Vr1zYB19q1a2XHjh3yj3/8w6ZnjkDQ13/ssO6yadse2bnniJn3zff75GzCeRk9qJPkD89jylHaX6PfVEsVj+SFgCtMemeV5M4VJg/9rWWwNwUBYpkMi2XTFJovU8gHNQsWLJBq1apJ1apV5e6775a3337bfKv2GDVqlPz444/yySefyM6dO2XatGmmJGQXDWDy5Mkj58+fv+Jyuo3t27eXDz/8MMNltDwVFxeXakL2Gj+ih/kW2v/p/wW+x0+ekb4j35J2zWrJoZgJsv+LVyWqUH7ZtvOAydYATqfv9X/NWyNTn7ubPjIns2wsPYVoUJM7J5SeNJhR7dq1k1OnTpmsSMuWf36b0BJQvXr15MYbb/T2xNhFA5kJEyaYx7z11lszXV4Dm5UrV2Z4v5aoxowZY9v2wT/jhv9V2jarJR0enGR6B1L64qtdUr/LGCkaFSEXLyVJ3JlzsmvFy7Jv5RZ2Mxxv49Y98tsfZ6R2x2e98y5dSpJn/vmhTJv3hWxf+nxQtw9wRFATGxsrX3/9tSxatMibNdH+Fg10PEGNlqW6desm3377rbRp00Y6d+5seluuxpNPPinPPPOMJCQkSMGCBeWVV17xqUlZM0hX6td56qmnZNiwYd7bmqmJjv6zWRWBpQGNDl/t+PA/5cDh4xkud+JUvPnZ7Mbr5ZoiBeWTdd/z0sDx7urQUFrcVDXVvO6Dp0qP9jdJ7443B227YC/LBUO6Qzqo0eDl4sWLpnclZeAQHh4uU6ZMMaOYtOSzf/9++fjjj2XVqlXSunVrGTBggIwfPz7Ljzt8+HDp27evCWhKlizp84un5a9KlSpleL9ut07IXuOf7CHd294ovZ54Q86cTZASxQqZ+XFnEsx5a1SvjjfLT3uPyu9/nJGbbqhk+m5en/tFqnPZADnZmbOJsvfgb97b+w8fl+9jD0nhqAISXaqoFC1cMNXy2lNWslikVKlYMghbi0CwbBy1FKIxTegGNRrMzJo1y5R/NAOTkmZj5s6dKw8//LC5rU3Cffr0MVOzZs1MUHI1QY325FSuXNmv39m1a5esWLHCZGMQWvp3b25+Lv/XkFTzHx3zrhnqrapUKCHPDrhTikQWkAOHT8iEGZ/K63NWB2V7gUDYtnO/dHz4/7y3n574Z/9fzzsayeuj72GnwxFCNqhZtmyZGdnUv3//y84ro+UmzeJoUPPss89KgwYNzDBrbcTV36tevXrAA66jR4+aIZDHjx83o6pefPFFqVu3rgmoEFqKNByY6TJjpiw1E+BUTRtcL398M8Xn5emjcZ6wMMtMdki2aT2uCWo0aLntttvSPVGeBjXjxo2T7du3m3PPaHZEzx6sQ681UzNv3ryAbtsPP/wgpUuXNicE1O2rUaOG2Qbt76G8BABAcFjJKcdHI1tpo7AGReG1HxAr15+n7AfcyJ8MAuDEY0HJYlFmpG1kZGTAjjVVH/9QcoVH2LLOS4nxEjuha8C22XGZGgAAYB/LBaOfQv7kewAAAL4gUwMAgAtYLhjSTaYGAAA4ApkaAABcwHJBTw1BDQAALmC5IKih/AQAAByBTA0AAC5guaBRmKAGAAAXsMTG8pOEZlRD+QkAADgCmRoAAFzAckH5iUwNAABwBDI1AAC4gOWCId0ENQAAuIBF+QkAACBnIFMDAIALWC4oP9EoDAAAAmrs2LHSsGFDKVSokJQoUUI6d+4ssbGx6S6bnJws7du3N4HT4sWL/XocghoAAFzUU2PZNPlj7dq1MmDAANm0aZOsWrVKLly4IG3atJH4+PjLlp00aVKWM0GUnwAAcAEriOWnFStWpLo9c+ZMk7HZsmWLNG/e3Dt/27ZtMmHCBNm8ebOULl3a7+0iqAEAAFkSFxeX6nZ4eLiZMnPq1Cnzs2jRot55Z8+elV69esnUqVOlVKlSWdoeyk8AALiBZWPp6b+JmujoaImKivJO2juTmaSkJBkyZIg0adJEatWq5Z0/dOhQady4sXTq1CnLT5FMDQAAyJKDBw9KZGSk97YvWRrtrdmxY4esX7/eO2/p0qWyevVq2bp1q1wNghoAAFzACkBPjQY0KYOazAwcOFCWLVsmMTExUq5cOe98DWj27NkjhQsXTrV8t27dpFmzZrJmzRqf1k9QAwCAC1hBPKOwDtMeNGiQLFq0yAQolSpVSnX/yJEj5f777081r3bt2jJx4kTp2LGjz49DUAMAAAJKS05z5syRJUuWmHPVHD161MzXPpz8+fObxuD0moPLly9/WQB0JQQ1AAC4gBXEId3Tpk0zP1u2bJlq/owZM6Rv375iF4IaAABcwApy+clfWfkdhnQDAABHIFMDAIALWFzQEgAAIGcgUwMAgAtYLsjUENQAAOACVhAbhbMLjcIAAMARyNQAAOAClgvKT2RqAACAI5CpAQDABSwX9NQQ1AAA4AIW5ScAAICcgUwNAAAuYNlYNgrR6hNBDQAAbhBmWWaya12hiNFPAADAESg/AQDgApYLRj+RqQEAAI5ApgYAABewXDCkm6AGAAAXCLP+nOxaVyii/AQAAByBTA0AAG5g2Vg2IlMDAAAQOGRqAABwAcsFQ7oJagAAcAHrv//sWlcoolEYAAA4ApkaAABcIMwFQ7oJagAAcAHLBSffo/wEAAAcgUwNAAAuYLlg9BOZGgAA4AhkagAAcIEwyzKTXesKRQQ1AAC4gOWC8pNPQU1cXJxERkZ6/38lnuUAAABCLqgpUqSIHDlyREqUKCGFCxdOdyhXcnKymX/p0qVAbCcAALgKlguGdPsU1KxevVqKFi1q/v/FF18EepsAAAACE9S0aNEi3f8DAICcwaKn5k/bt2/3eafdcMMNAXtBAABA1oQx+ulPdevWNfUz7Zu5EnpqAABASJef9u7dG/gtAQAAAWP9d7JrXTk2qKlQoULgtwQAACA7L5PwzjvvyPLly723R4wYYYZ5N27cWPbv33812wIAAAI8pNuyaXJEUPPyyy9L/vz5zf83btwoU6ZMkXHjxknx4sVl6NChgdhGAABwlcIseydHXCbh4MGDUrlyZfP/xYsXS/fu3eXBBx+UJk2aSMuWLQOxjQAAAPZnagoWLCjHjx83/1+5cqXcfvvt5v/58uWTc+fO+bs6AACQDSwXlJ/8ztRoEHP//fdLvXr15KeffpIOHTqY+T/88AMNxQAAhDArNGOR4GVqpk6dKrfccov89ttv8sEHH0ixYsXM/C1btkivXr0CsY0AAAD2BzU60kmbg5csWSLt2rXzzh8zZox06tTJ39UBAACHl5/Gjh0rDRs2lEKFCpmLY3fu3FliY2O99584cUIGDRokVatWNYORypcvL4MHD5ZTp04FNqhJ6/Tp0/LGG29Io0aNpE6dOle7OgAA4DBr166VAQMGyKZNm2TVqlVy4cIFadOmjcTHx5v7Dx8+bKbx48fLjh07ZObMmbJixQrp379/YHtqPGJiYuStt94yJagyZcpI165dTQYHAACEnjAbh2L7ux4NUFLSoEUzNtq60rx5c6lVq5aJJzyuu+46eemll+Tuu++WixcvSu7cue0Pao4ePWo2RIOZuLg46dGjhyQmJpqh3TVq1PBnVQAAIBtZNo5a8qxHY4GUwsPDzZQZT1mpaNGiV1wmMjLS54DGr/JTx44dTa1Lr9g9adIkkyaaPHmyzw8EAACcJTo6WqKioryT9s5kJikpSYYMGWLOb6cZmvT8/vvv8sILL5jz4PnD5/Dnk08+MU07jzzyiFSpUsWvBwEAAM67oOXBgwdNNsXDlyyN9tZo38z69evTvV+zP3fccYepAI0ePdqv7fI5U6MPrk3BDRo0ME3B2j+jkRQAAHCnyMjIVFNmQc3AgQNl2bJl8sUXX0i5cuUuu1/jDB1ZraOkFi1aJHny5AlMUHPzzTfLv//9bzly5Ig89NBDMm/ePNMgrGkk7WTWDQEAAKEpzLJsnfyRnJxsAhoNVFavXi2VKlVKN0OjI6Ly5s0rS5cuNVcq8Ps5+vsLERER0q9fP5O5+f777+Xxxx+XV155xXQx33nnnX5vAAAACDzLsnfyh5acZs+eLXPmzDFZGB14pJPn8kqegEaHeHsGI3mWuXTpUvacp0Ybh/UK3YcOHZK5c+dezaoAAIBDTZs2zYxm0gtfly5d2jvNnz/f3P/tt9/KV199ZZIletHslMto307Az1OTUq5cuczZAXUCAADuGNLtT/npSjTYyWyZbAtqAABAaLOyUDa60rpC0VVfJgEAACAUkKkBAMAFwrIwaulK6wpFZGoAAIB7MjU6XtxXDOsGACD0WC7oqfEpqPF1VJN2Q/sznhwAADh/9FNIBTV61mAAAADHNgonJCRk6TTGSO2HT8ZKoRQXBAPcpkibl4O9CUDQJF9MyLYm2jAb1xWK/N4uLS/p5cDLli0rBQsWlF9++cXMHzVqlDm1MQAAQI4Ial566SWZOXOmuTyCXnTKo1atWvLmm2/avX0AAMDGnhrLpskRQc2sWbPkjTfekN69e5vLI3jUqVNHdu3aZff2AQAAG1iWnl/GnilEYxr/g5pff/3VXGwqvWbiCxcu2LVdAAAAgQ1qatSoIevWrbts/sKFC6VevXr+rg4AAGSDMMveyRGjn5599lnp06ePydhodubDDz+U2NhYU5ZatmxZYLYSAADA7kxNp06d5KOPPpLPPvtMIiIiTJCzc+dOM+/222/3d3UAACAbWC5oFM7SeWqaNWsmq1atsn9rAABAQITZWDZyTPnJY/PmzSZD4+mzadCggZ3bBQAAENig5tChQ9KzZ0/58ssvpXDhwmbeyZMnpXHjxjJv3jwpV66cv6sEAAABZrnggpZ+99Tcf//9Zui2ZmlOnDhhJv2/Ng3rfQAAIPSEWZatkyMyNWvXrpUNGzZI1apVvfP0/5MnTza9NgAAADkiqImOjk73JHt6TagyZcrYtV0AAMBGYVzQ8nKvvvqqDBo0yDQKe+j/H3vsMRk/fjxvQAAAELqZmiJFiqQakx4fHy+NGjWS3Ln//PWLFy+a//fr1086d+4cuK0FAABZYrmgUdinoGbSpEmB3xIAABAwYWJfg6+uK8cGNXpZBAAAgFCW5ZPvqYSEBDl//nyqeZGRkVe7TQAAwGaWC8pPfp+nRvtpBg4cKCVKlDDXftJ+m5QTAABAjghqRowYIatXr5Zp06ZJeHi4vPnmmzJmzBgznFuv1A0AAEL32k9hNk2OKD/p1bg1eGnZsqXcd9995oR7lStXlgoVKsh7770nvXv3DsyWAgCAqyoZhdlUN3JM+Ukvi3Dttdd6+2f0tmratKnExMTYv4UAAACBCGo0oNm7d6/5f7Vq1WTBggXeDI7nApcAACA0G4UtmyZHBDVacvruu+/M/0eOHClTp06VfPnyydChQ2X48OGB2EYAAHCVwuipuZwGLx633Xab7Nq1S7Zs2WL6am644QbedAAAIGdkatLSBuGuXbtK0aJF5cEHH7RnqwAAgK0sm/85MqjxOH78uLz11lt2rQ4AACD7zigMAAByhjAbzy/jmPPUAACAnCfMBUGNbeUnAACAHJGp0WbgKzl58qQd2wMAAALAsiwz2bWuHB3UREVFZXr/vffea8c2AQAABC6omTFjhv9rBwAAISHMBT01NAoDAOAClo2XNwjR6hONwgAAwBnI1AAA4AJhlmUmu9YVihjSDQCAC4QF8YKWY8eOlYYNG0qhQoWkRIkS0rlzZ4mNjU21TEJCggwYMECKFSsmBQsWlG7dusl//vMf/56jf5sFAADgn7Vr15qAZdOmTbJq1Sq5cOGCtGnTRuLj41NdMPujjz6S999/3yx/+PDhTE8nkxblJwAA3MCyscHXz/WsWLEi1e2ZM2eajM2WLVukefPmcurUKXP9yDlz5sitt97qHXVdvXp1EwjdfPPNPj0OmRoAAJCtNIhRRYsWNT81uNHszW233eZdplq1alK+fHnZuHGjz+slUwMAgAuEiWUmu9al4uLiUs0PDw8305UkJSXJkCFDpEmTJlKrVi0z7+jRo5I3b14pXLhwqmVLlixp7vN9uwAAgGvOU2PZNKno6GhzRQHPpA3BmdHemh07dsi8efNsf45kagAAQJYcPHhQIiMjvbczy9IMHDhQli1bJjExMVKuXDnv/FKlSsn58+fNdSRTZmt09JPe5ysyNQAAuEBYAIZ0a0CTcsooqElOTjYBzaJFi2T16tVSqVKlVPc3aNBA8uTJI59//rl3ng75PnDggNxyyy0+P0cyNQAAIKC05KQjm5YsWWLOVePpk9GSVf78+c3P/v37y7Bhw0zzsAZIgwYNMgGNryOfFEENAAAuEBbEMwpPmzbN/GzZsmWq+Tpsu2/fvub/EydOlLCwMHPSvcTERGnbtq28/vrrfj0OQQ0AAC5gBfGCllp+yky+fPlk6tSpZsoqemoAAIAjkKkBAMAt56mx7D1PTaghUwMAAByBTA0AAC5gBbGnJrsQ1AAA4AJhNpZnQrXME6rbBQAA4BcyNQAAuIBlWWaya12hiKAGAAAXsP472bWuUET5CQAAOAKZGgAAXCAsiJdJyC5kagAAgCOQqQEAwCUscTaCGgAAXMBywcn3KD8BAABHIFMDAIALWC44Tw2ZGgAA4AhkagAAcIEwF1z7iaAGAAAXsCg/AQAA5AxkagAAcAHLBdd+IqgBAMAFLMpPAAAAOQOZGgAAXCDMBaOfQnW7AAAA/EKmBgAAF7Bc0FNDUAMAgAtYLhj9RPkJAAA4ApkaAABcwLL+nOxaVygiUwMAAByBTA0AAC4QJpaZ7FpXKCKoAQDABSzKTwAAADkDmRoAAFzA+u8/u9YVimgUBgAAjkCmBgAAF7Bc0FNDUAMAgAtYNo5+ovwEAAAQQGRqAABwAYvyEwAAcALLBUENo58AAIAjUH4CAMAFLM5TAwAAkDOQqQEAwAXCrD8nu9YVinJ0ULNv3z6pVKmSbN26VerWrStr1qyRVq1ayR9//CGFCxcO9uYhRHy1bY/8a95q+T72kBw7HidvvNRP2jar7b3/8ZfnyMIV36T6nRY3VZNZ4x8KwtYC9hv6t1vkL02qSpXoYpJw/qJ8/eMhGf3mF7L70AnvMiWKRMjzD9wqLetXkoIF8srugydkwtwv5aP1sbwkDmFRfsoeGzdulFy5cskdd9xxVetp3LixHDlyRKKioi67TwMey7KuOOkyM2fO9N4OCwuTcuXKyX333SfHjh3zrivl70REREiVKlWkb9++smXLlqvafgTG2YTzUv26svLC0G4ZLtOiUTX5ZtEY7zT5uXt4OeAYjWuXlzeXbpE2j70jXUfOlTy5csmHY3tKgXx5vMtMG9FRKpcrJr2ee1+aPPimfPRlrMx4uovUvq5kULcdyHGjn9566y0ZNGiQxMTEyOHDh7O8nrx580qpUqVMsJFRwOOZevToIe3atUs1T5dRkZGR5vahQ4fk3//+t3zyySdyzz2pD3IzZswwy/zwww8ydepUOXPmjDRq1EhmzZqV5e1HYLS6uboMf6CDtGt+Q4bLhOfJLSWKRXqnqEIFeDngGH99er7MXfW97Nr/u+z45Zg8On6ZRJeMkrpVSnmXualGOfn3ks3ybewR2X/0pEyY86Wcik9ItQycMaTbsmnylx7jO3bsKGXKlDHH6cWLF6e6X4+jAwcONMmE/PnzS40aNWT69Ok5K6jRJzF//nx55JFHTKZGMyUpaSmpd+/ecs0115gnqVkRDSjS48nGnDx5MsOAxzPpusLDw1PN02WUrkNv645v3769DB48WD777DM5d+6cd31a3tJlKlasKG3atJGFCxea7dQXRLcZOcumbbul/p2jpFXvl+XpCe/LH6fig71JQMBERoSbn3+cTvDO05JUlxbVpXChfOaA1bVlDQnPm1vWbz/AKwFbxMfHS506dUwiID3Dhg2TFStWyOzZs2Xnzp0yZMgQc0xdunRpzglqFixYINWqVZOqVavK3XffLW+//bYkJyd77x81apT8+OOPJluiT3LatGlSvHjxbN1GDYCSkpLk4sWLV1xu6NChcvr0aVm1alW2bRuunpaeXvt7b5kz8REZ+XBH2bRtj/QZ/oZcupTE7oXjaMAy9uHbZNOOg7Jz32/e+fe9uEhy584lez8YJv9Z/qRMfKyd3DPmA9l7mC9pTmGl6Ku5+n/+0yTBiy++KF26dEn3/g0bNkifPn2kZcuWJmHw4IMPmiDo66+/zjmNwlp60mBGaTno1KlTsnbtWvOk1IEDB6RevXpy4403mtv6RLPTzz//bNJf+viFChW64rIanHkamNOTmJhoJo+4uDibtxZZcWfr+t7/V7uujFS/rrQ0+9tLsnHbbmna4Hp2Khxl/MB2Ur3iNdJ+2Lup5j/dp4VEFQyXTiPmyIm4s9Kh8fWmp6bDsHflxxTBD3KusACMfkp7HNMKiE5ZoS0gmpXp16+fqZRo9eWnn36SiRMn+r5dEkSxsbEmAuvZs6e5nTt3brnrrrtMoOOhZal58+aZ0U0jRowwkVygaWBVsGBBKVCggMkglSxZUt57771Mf8+TYUqvp0eNHTvWNDF7pujoaNu3HVevfJniUjQqQvYf+p3dCUcZN6CNtL25snQc8Z4c/v20d37F0oXlwc43yqAJyyVm2z7TdzNu9nrZ+tMRuf/OBkHdZoS26OjoVMc1Pc5l1eTJk00fjfbUaDuIJjq0VNW8efOckanR4EVLOhqRpQwMNMqbMmWK2UGartq/f798/PHHpqzTunVrGTBggIwfPz5g26UZmW+//daMfipdurQpP/lCy2NKh5mn56mnnjI1Qw+NcAlsQs+RYyflj7izpmEYcFJAc0eTqtLxidly4OipVPcVCP9zFFRS0v9K/+pSUrJYoXpCEoTEkO6DBw+awTUeWc3SeIKaTZs2mWxNhQoVTGOxHu81RrjttttCO6jRYEZHCk2YMME02qbUuXNnmTt3rjz88MPmtjYJa51Np2bNmsnw4cMDGtRoMFO5cmW/f2/SpEnmxc1o519NWg5ZF382Ufb9+r+sy8Ejx+WHn3+VwpEFpHChAjJp5qfSvsUNck3RSNl/+HcZO+0jqVi2uDS/6c9yIpDTjR/UVrq3qim9nlsoZ86dN+ekUXHxiea8NT8dPC57fj0hE4e0l1FvfC4n4s7JHY2vl1b1K8nfRi0I9uYjhC9oGRkZmSqoySodiPP3v/9dFi1a5D29yw033CDbtm0zx/uQD2qWLVtmRgn179//svPKdOvWzWRxNKh59tlnpUGDBlKzZk3Tj6K/V716dQk2HWF19OhRs01a8/vXv/5lhqdpoMaJ/0LL9tiD8rfH/tdt/8KUJeZn93YN5aXHu8uuPYflgxXfSNyZc1KyeKQ0a1hVHu/fwYz8AJygf8c/S0jLJ/zZv+jx6KsfmaHeFy8lSY+n58tz/VvJ3Od7SET+PLL31z/M/au+2ROkrYabXLhwwUyaVEhJz2GnA3V8FbRPbQ1aNPJK70R5GtSMGzdOtm/fbupqWrbR5lstA2mmRntsgk1PyKfy5csnZcuWlaZNm5r+oPr1/9d0itBwS73Ksj8m40azdyf8mREEnKpIm5czXeaXw39Inxc+zJbtQTBHP9nDyuIpXHbv3u29vXfvXpOJKVq0qJQvX15atGhhKjF6rNfykw4a0kTBa6+95vt2JaccP41spT01GtTtPvS7FLIhfQfkVBU6vRrsTQCCJvligiSue8EMUrGjlJPRsebTb/dJREF71h9/Jk7a1q/o1zZ7LmWUlraW6DnqtPqhSYyVK1fKiRMnTGCjw7r1dCkZDcBJi/w6AAAuECaWhNnUVKPr8peequVKeRQ9oW1GJ9f1FUENAAAuYAW5/JQdgn5GYQAAADuQqQEAwA0s56dqyNQAAABHIFMDAIALWAE4o3CoIagBAMANLPvOKByiMQ3lJwAA4AxkagAAcAHL+X3CBDUAALiC5fyohtFPAADAESg/AQDgApYLRj+RqQEAAI5ApgYAABewbBzSbdvQcJsR1AAA4AKW8/uEKT8BAABnIFMDAIAbWM5P1dAoDAAAHIFMDQAALmC5YEg3QQ0AAC5guWD0E+UnAADgCGRqAABwAcv5fcJkagAAgDOQqQEAwA0s56dqCGoAAHABywWjn2gUBgAAjkCmBgAAF7BcMKSboAYAABewnN9SQ/kJAAA4A5kaAADcwHJ+qoZGYQAA4AhkagAAcAHLBUO6CWoAAHABywWjnyg/AQAARyBTAwCAC1jO7xMmUwMAAJyBTA0AAG5gOT9VQ1ADAIALWC4Y/USjMAAAcAQyNQAAuIDlgiHdBDUAALiA5fyWGspPAADAGcjUAADgBpbzUzU0CgMAAEcgUwMAgAtYLhjSTVADAIAbWDaOWgrNmIbyEwAACLyYmBjp2LGjlClTRizLksWLF1+2zM6dO+XOO++UqKgoiYiIkIYNG8qBAwd8fgx6agAAcFGfsGXT5K/4+HipU6eOTJ06Nd379+zZI02bNpVq1arJmjVrZPv27TJq1CjJly+fz49B+QkAAARc+/btzZSRp59+Wjp06CDjxo3zzrvuuuv8egwyNQAAuIFlf6omLi4u1ZSYmJilTUtKSpLly5fL9ddfL23btpUSJUpIo0aN0i1RXQlBDQAALhr9ZNn0T0VHR5v+F880duzYLG3bsWPH5MyZM/LKK69Iu3btZOXKldKlSxfp2rWrrF271uf1UH4CAABZcvDgQYmMjPTeDg8Pz3KmRnXq1EmGDh1q/l+3bl3ZsGGDTJ8+XVq0aOHTeghqAABwASsAF7TUgCZlUJNVxYsXl9y5c0uNGjVSza9evbqsX7/e5/VQfgIAAEGVN29eM3w7NjY21fyffvpJKlSo4PN6yNQAAOACVpAv/aQ9M7t37/be3rt3r2zbtk2KFi0q5cuXl+HDh8tdd90lzZs3l1atWsmKFSvko48+MsO7fUVQAwCAG1jBjWo2b95sghWPYcOGmZ99+vSRmTNnmsZg7Z/RZuPBgwdL1apV5YMPPjDnrvEVQQ0AAAi4li1bSnJy8hWX6devn5myiqAGAAAXsLigJQAAcEz1ybJvXaGI0U8AAMARKD8BAOACVpBHP2UHMjUAAMARyNQAAOACVgDOKBxqCGoAAHAFy/EFKMpPAADAEcjUAADgApYLyk9kagAAgCOQqQEAwAUsx3fUENQAAOAKFuUnAACAnIHyEwAALmBxQUsAAOAIlvObahj9BAAAHIHyEwAALmA5P1FDpgYAADgDmRoAAFzAcsGQboIaAABcwHLB6CcahQEAgCOQqQEAwA0s53cKk6kBAACOQKYGAAAXsJyfqCGoAQDADSwXjH6i/AQAAByB8hMAAK5g2TgUOzRTNQQ1AAC4gEX5CQAAIGegpwYAADgCQQ0AAHAEemoAAHABywU9NQQ1AAC4gOWCC1oS1ARRcnKy+Xn69OlgbgYQdMkXE4K9CUDQJF9MTHVMQNYR1ASRJ5ipV71SMDcDABAix4SoqKiArd+i/IRAKlOmjBw8eFAKFSokVqgWKB0uLi5OoqOjzesQGRkZ7M0Bsh1/A8GnGRoNaPSYgKtDpiaIwsLCpFy5csHcBPyXBjQENXAz/gaCK5AZGg8uaAkAAJzBcv5lujlPDQAAcATKT3C18PBwee6558xPwI34G3APywVDuq1kxpABAODoZvCoqCj59dhJ23oHdZ1lSxSWU6dOhVQ/IpkaAABcwGJINwAAcALL+X3CNAoDAABnYPQTHKlv377SuXNn7+2WLVvKkCFDvLcrVqwokyZNsmXdQCjgPQ+fUzV2TX6KiYmRjh07mpMM6glnFy9enOGyDz/8sFnG389pghpk64euvkl1yps3r1SuXFmef/55uXjxYsAf+8MPP5QXXnjBlnX985//lJkzZ2Z4/5o1a7zPU6eSJUtKt27d5JdffkkVVHnuz58/v7ndo0cPWb16tS3biNDghvd8yueY3qTvbc8XC8+8fPnySY0aNeT111/3rkfX77k/V65cUqRIEWnUqJHZX9qMCvtGP1k2/fNXfHy81KlTR6ZOnXrF5RYtWiSbNm3K0hmWCWqQrdq1aydHjhyRn3/+WR5//HEZPXq0vPrqq+kue/78edset2jRouZyFHbQUQSFCxfOdLnY2Fg5fPiwvP/++/LDDz+YbyiXLl3y3q8f1rovdLlZs2aZdd52223y0ksv2bKdCA1Of89rwKPPzzOpGTNmeG9/88033mUfeOABM+/HH380QfyAAQNk7ty53vt1FI3ef+jQIdmwYYM8+OCD5m+jbt265m8JOVv79u3lxRdflC5dumS4zK+//iqDBg2S9957T/LkyeP3YxDUINvPiVGqVCmpUKGCPPLII+YgvnTp0lTpcz2oa4RetWpVM1+vy6QfgPqhqh/UnTp1kn379nnXqYHCsGHDzP3FihWTESNGXHa127Tlp7TefPNN8/uff/65ub1w4UKpXbu2yaLoOnU79VtGyu3MTIkSJaR06dLSvHlzefbZZ80H+e7du7336wFH90X58uXNMm+88YaMGjXKLKuBDpzB6e95DXj0+Xkmpev13L7mmmu8yxYoUMDMu/baa01wV6VKFe++UJql0fv176Z69erSv39/E9ycOXPGPEfYM/rJsmmyW1JSktxzzz0yfPhwqVmzZpbWQVCDoNIP0JTfTvUDVg/oq1atkmXLlsmFCxekbdu2JgBYt26dfPnll1KwYEHz7dfzexMmTDCp67ffflvWr18vJ06cMOlLX40bN05GjhwpK1eulNatW5tvij179pR+/frJzp07TTmpa9eulx00/H2evnwTf+yxx8zjLFmyJMuPhdDmlvd8VvZFRl8OevfubYKflJlOZO3cMnE2TumtMzExMcsvzT/+8Q/JnTu3DB48OMvr4Dw1CAr9sNQP808//dSkGj0iIiLMN0jtP1CzZ8820bvO81zJXFPb+k1QP3jbtGljGsmeeuop8yGspk+fbtbriyeffFLeffddWbt2rfebgX7Aa8+Drk+/XSv9BptVur7x48dL2bJlvd/EM6LfyvVDPOW3cjiDm97zmdHgRMtO27dvNyWmzFSrVs1cxfr48ePm7wP+yZs3r8mAVakUbeuu02A7Ojr1OvUM7ZqF89eWLVtMKfPbb7/1vu+zgqAG2Uq/ieofgn4b1Q/uXr16pfoD0A9Sz4e7+u6770zJJm1vQEJCguzZs8c0EOoHsjYUemikf+ONN2b6LVO/7Wp6ffPmzSYd7qGNbPrtVbdFvzHrQaR79+6mcdEfegV23YazZ8+adX7wwQepnltG9Heu5o8aocVN7/nMaGOwBmuandFm4KFDh5qSXGY8z4u/i6zJly+f7N2719aeLc/rkvY1yeolZzQreezYMVOOTxn8ah+aBvG+ftEjqEG2atWqlUybNs18iGsPgX4Yp6TfWlPSWnqDBg1M01haKWv1WdGsWTNZvny5LFiwwKTiPfTDVksBWsvX9PzkyZPl6aeflq+++koqVark1x+pNj7qN0tfGzb1m+hvv/3m1+MgtLnpPZ8ZLSPperXspH0zYWG+dUBoSUz/lrTXB1kPbPLlyxeyu097abSPKyUNsHX+fffd5/N6CGqQrfQDXIe1+qp+/foyf/58ExhkdH0R/XDUD19ttlWaRtdUpv7uldx0000ycOBA06ugB5onnnjCe59++2jSpImZtHFXU/Las6DNmb7Sg4Evo6RS0vSrftBzHhzncNN7PjPaVOzPvlD67X3OnDnmb8LXIAihSQP2lIMlNHu0bds2U3bXDE3aoFVHP2nZLLOyfUoENQhp+s1Oh7/q6A8dAq0lnf3795tzcOhoCL2tzbWvvPKKGUmhtffXXntNTp486dP6GzduLB9//LEZaqgf8jpaRA8W2vugKXg9sOhtzZ7oaAw7aY/A0aNHTVlC/7i1l0JT82PHjvX7gx/O4eT3vC/lDP2b0J/6fDZu3Cgvv/yyCYb0+SJn27x5s8lcengC5j59+lzx3F/+IKhBSNMhoHoWSm1u1CZGDQS04Vbr/55vsVpz1R4D/cPQb3I6gkPPg+DrCbuaNm1qUvIdOnQwaXhNgepjah1Xu/n1G6v2IuhBwE76bVgnTxPfzTffbA4sKf/o4T5Ofs9nRh9bs1CaNdLnqt/Q9TlqEBdKV4JG1uhpBvwZUZeVARNWcqDH7AEAAGQDCpQAAMARCGoAAIAjENQAAABHIKgBAACOQFADAAAcgaAGAAA4AkENAABwBIIaAEHXt2/fVJeG0JN06ZluPSpWrGhODGfHugE4F2cUBnDFgOCdd97xXodFr89y7733yt///vfLLsxoJ70kgD6eHfR6WpxjFHAHghoAV6QXP5wxY4YkJiaaawYNGDDABBxPPfVUquXOnz9vLvlgB73AnV30ukEA3IHyE4ArCg8PN9em0usBPfLII+Y6QUuXLvWWdV566SUpU6aM90q6Bw8elB49epgrlGtwohdmTHkNl0uXLpkL2en9elVevUhj2kxK2vJTWnrhT/19vVaWWrhwodSuXVvy589v1qnbGB8fb+6j/AS4B0ENAL9o4KBZGaVBRWxsrKxatUqWLVtmrjjetm1bKVSokKxbt06+/PJLKViwoMn2eH5HL5SoV+R9++23Zf369XLixAlZtGiRz48/btw4GTlypKxcudJc5FEv7NizZ09zUcedO3fKmjVrzIUgKTkB7kP5CYBPNEjQIObTTz+VQYMGyW+//SYREREma+IpO82ePVuSkpLMPL3SstLSlWZVNNho06aNafjV0pUGHmr69Olmnb7QK1e/++67snbtWqlZs6aZp0HNxYsXzfo0m6Q0awPAfQhqAFyRZmA026JZGA1YevXqJaNHjza9NRo8pOyj+e6772T37t0mU5NSQkKC7NmzR06dOmWCkEaNGv3vQyh3brnxxhszzaxohkdLSps3b5Zrr73WO79OnTomY6PbolkiDZy6d+8uRYoU4ZUFXIbyE4AratWqlWzbtk1+/vlnOXfunBkNpRka5fnpcebMGWnQoIFZPuX0008/mWDoajRr1sz04yxYsCDV/Fy5cpny1yeffCI1atSQyZMnm/6evXv38soCLkNQA+CKNHCpXLmyGc6d2TDu+vXrm+CnRIkS5ndSTjoKSafSpUvLV1995f0dLR1t2bIl01fhpptuMoHLyy+/LOPHj091n5a6mjRpImPGjJGtW7ea7JE/fToAnIGgBoBtevfuLcWLFzcjnrRRWLMl2kszePBgOXTokFnmsccek1deeUUWL14su3btkkcffVROnjzp0/obN25shpVr8OI5GZ8GSBroaFnqwIED5hw32u9TvXp1XlnAZeipAWCbAgUKSExMjGno1cbd06dPS9myZU3PS2RkpFnm8ccfN301ffr0kbCwMDNqqUuXLqbfxhdNmzaV5cuXS4cOHUzpSYdv62NqkBMXF2eahbX/pn379ryygMtYyYx7BAAADkD5CQAAOAJBDQAAcASCGgAA4AgENQAAwBEIagAAgCMQ1AAAAEcgqAEAAI5AUAMAAByBoAYAADgCQQ0AAHAEghoAAOAIBDUAAECc4P8BgnQY+rzykeAAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from collections import Counter\n", + "\n", + "from train_model import DATA_DIR, build_cv, build_grid_search\n", + "from features import load_dataset\n", + "from sklearn.model_selection import cross_val_predict\n", + "from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay\n", + "import matplotlib.pyplot as plt\n", + "\n", + "# 1. Load dataset\n", + "X, y, files = load_dataset(DATA_DIR)\n", + "\n", + "print(\"Total data:\", len(files))\n", + "print(\"Distribusi label:\", dict(Counter(y)))\n", + "print(\"Jumlah fitur:\", X.shape[1])\n", + "\n", + "# 2. Buat cross-validation dan model\n", + "cv = build_cv(y)\n", + "grid_search = build_grid_search(cv)\n", + "\n", + "# 3. Training GridSearch\n", + "grid_search.fit(X, y)\n", + "\n", + "best_model = grid_search.best_estimator_\n", + "\n", + "print(\"Best params:\", grid_search.best_params_)\n", + "print(\"Best CV f1_macro:\", grid_search.best_score_)\n", + "print(\"Urutan kelas:\", list(best_model.classes_))\n", + "\n", + "# 4. Prediksi cross-validation\n", + "y_pred = cross_val_predict(best_model, X, y, cv=cv, n_jobs=1)\n", + "\n", + "# 5. Confusion matrix\n", + "labels = [\"PD\", \"TPD\"]\n", + "cm = confusion_matrix(y, y_pred, labels=labels)\n", + "\n", + "print(\"Urutan label:\", labels)\n", + "print(cm)\n", + "\n", + "# 6. Tampilkan gambar confusion matrix\n", + "disp = ConfusionMatrixDisplay(\n", + " confusion_matrix=cm,\n", + " display_labels=[\"PD\", \"TPD\"]\n", + ")\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 5))\n", + "disp.plot(cmap=\"Blues\", values_format=\"d\", ax=ax, colorbar=True)\n", + "\n", + "ax.set_title(\"Confusion Matrix Model SVM\")\n", + "ax.set_xlabel(\"Prediksi\")\n", + "ax.set_ylabel(\"Label Asli\")\n", + "ax.set_xticklabels([\"Prediksi PD\", \"Prediksi TPD\"])\n", + "ax.set_yticklabels([\"Asli PD\", \"Asli TPD\"])\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d509eed5-2719-4c11-98cd-67c157034f07", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.metrics import accuracy_score, balanced_accuracy_score, precision_score, recall_score, f1_score, classification_report\n", + "\n", + "print(\"Accuracy :\", accuracy_score(y, y_pred))\n", + "print(\"Balanced Accuracy :\", balanced_accuracy_score(y, y_pred))\n", + "print(\"Precision Macro :\", precision_score(y, y_pred, average=\"macro\", zero_division=0))\n", + "print(\"Recall Macro :\", recall_score(y, y_pred, average=\"macro\", zero_division=0))\n", + "print(\"F1 Macro :\", f1_score(y, y_pred, average=\"macro\", zero_division=0))\n", + "\n", + "print(classification_report(\n", + " y,\n", + " y_pred,\n", + " labels=labels,\n", + " target_names=[\"PD - Percaya Diri\", \"TPD - Tidak Percaya Diri\"],\n", + " zero_division=0\n", + "))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6d7215c2-6d9b-4233-921b-d79e262912a1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fitting 5 folds for each of 16 candidates, totalling 80 fits\n" + ] + }, + { + "data": { + "text/html": [ + "
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=42, shuffle=True),\n",
+       "             estimator=Pipeline(steps=[('scaler', StandardScaler()),\n",
+       "                                       ('svm',\n",
+       "                                        SVC(class_weight='balanced',\n",
+       "                                            probability=True,\n",
+       "                                            random_state=42))]),\n",
+       "             n_jobs=1,\n",
+       "             param_grid={'svm__C': [0.1, 1, 10, 100],\n",
+       "                         'svm__gamma': ['scale', 0.01, 0.001, 0.0001],\n",
+       "                         'svm__kernel': ['rbf']},\n",
+       "             scoring='f1_macro', verbose=1)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=42, shuffle=True),\n", + " estimator=Pipeline(steps=[('scaler', StandardScaler()),\n", + " ('svm',\n", + " SVC(class_weight='balanced',\n", + " probability=True,\n", + " random_state=42))]),\n", + " n_jobs=1,\n", + " param_grid={'svm__C': [0.1, 1, 10, 100],\n", + " 'svm__gamma': ['scale', 0.01, 0.001, 0.0001],\n", + " 'svm__kernel': ['rbf']},\n", + " scoring='f1_macro', verbose=1)" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cv = build_cv(y)\n", + "grid_search = build_grid_search(cv)\n", + "\n", + "grid_search.fit(X, y)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "83e533a4-bb17-477f-9566-d765fbbf7cc7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Best params: {'svm__C': 10, 'svm__gamma': 'scale', 'svm__kernel': 'rbf'}\n", + "Best CV f1_macro: 0.6554293822792274\n", + "Urutan kelas: ['PD', 'TPD']\n" + ] + } + ], + "source": [ + "best_model = grid_search.best_estimator_\n", + "\n", + "print(\"Best params:\", grid_search.best_params_)\n", + "print(\"Best CV f1_macro:\", grid_search.best_score_)\n", + "print(\"Urutan kelas:\", list(best_model.classes_))" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e2279016-05d5-4a05-b199-d99c3ad2cf21", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Evaluasi Cross-Validation ===\n", + "Accuracy : 0.6628\n", + "Balanced Accuracy : 0.6628\n", + "Precision Macro : 0.6629\n", + "Recall Macro : 0.6628\n", + "F1 Macro : 0.6627\n", + "\n", + "=== Classification Report ===\n", + " precision recall f1-score support\n", + "\n", + " PD - Percaya Diri 0.66 0.67 0.67 43\n", + "TPD - Tidak Percaya Diri 0.67 0.65 0.66 43\n", + "\n", + " accuracy 0.66 86\n", + " macro avg 0.66 0.66 0.66 86\n", + " weighted avg 0.66 0.66 0.66 86\n", + "\n", + "=== Confusion Matrix ===\n", + "Urutan label: ['PD', 'TPD']\n", + "[[29 14]\n", + " [15 28]]\n", + "\n", + "=== Ringkasan Benar/Salah per Kelas ===\n", + "PD: benar=29, salah=14, total=43\n", + "TPD: benar=28, salah=15, total=43\n", + "\n", + "=== File yang Salah Prediksi ===\n", + "c_pd.wav | PD | TPD\n", + "c_tpd.wav | TPD | PD\n", + "f_tpd.wav | TPD | PD\n", + "h_tpd.wav | TPD | PD\n", + "j_pd.wav | PD | TPD\n", + "j_tpd.wav | TPD | PD\n", + "k_tpd.wav | TPD | PD\n", + "m_pd.wav | PD | TPD\n", + "p10_pd.wav | PD | TPD\n", + "p10_tpd.wav | TPD | PD\n", + "p2_pd.wav | PD | TPD\n", + "p3_pd.wav | PD | TPD\n", + "p4_pd.wav | PD | TPD\n", + "p5_tpd.wav | TPD | PD\n", + "p6_tpd.wav | TPD | PD\n", + "p8_pd.wav | PD | TPD\n", + "p_pd.wav | PD | TPD\n", + "q_pd.wav | PD | TPD\n", + "s_tpd.wav | TPD | PD\n", + "w3_pd.wav | PD | TPD\n", + "w3_tpd.wav | TPD | PD\n", + "w4_pd.wav | PD | TPD\n", + "w5_pd.wav | PD | TPD\n", + "w5_tpd.wav | TPD | PD\n", + "w6_tpd.wav | TPD | PD\n", + "w7_tpd.wav | TPD | PD\n", + "w8_pd.wav | PD | TPD\n", + "w8_tpd.wav | TPD | PD\n", + "w_tpd.wav | TPD | PD\n" + ] + } + ], + "source": [ + "evaluate_model(best_model, X, y, files, cv)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "03747dfc-acbf-42aa-9814-efa02e1d1d5f", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import seaborn as sns\n", + "\n", + "from sklearn.metrics import confusion_matrix\n", + "from sklearn.model_selection import cross_val_predict\n", + "from features import LABEL_PD, LABEL_TPD" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a4e120f6-e45e-4a57-ab29-3cf80ee91cf5", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/user/TA/confivoice3/ml/features.py:70: UserWarning: PySoundFile failed. Trying audioread instead.\n", + " y, sr = librosa.load(file_path, sr=sample_rate, mono=True)\n", + "/Users/user/TA/confivoice3/cv_app/.venv/lib/python3.10/site-packages/librosa/core/audio.py:184: FutureWarning: librosa.core.audio.__audioread_load\n", + "\tDeprecated as of librosa version 0.10.0.\n", + "\tIt will be removed in librosa version 1.0.\n", + " y, sr_native = __audioread_load(path, offset, duration, dtype)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "=== Distribusi Label Dataset ===\n", + "PD : 43\n", + "TPD: 43\n", + "Total data: 86\n", + "Distribusi label: {'PD': 43, 'TPD': 43}\n", + "Jumlah fitur: 78\n", + "Fitting 5 folds for each of 16 candidates, totalling 80 fits\n", + "Best params: {'svm__C': 10, 'svm__gamma': 'scale', 'svm__kernel': 'rbf'}\n", + "Best CV f1_macro: 0.6554293822792274\n", + "Urutan kelas: ['PD', 'TPD']\n" + ] + } + ], + "source": [ + "from collections import Counter\n", + "\n", + "from train_model import DATA_DIR, build_cv, build_grid_search\n", + "from features import load_dataset\n", + "\n", + "X, y, files = load_dataset(DATA_DIR)\n", + "\n", + "print(\"Total data:\", len(files))\n", + "print(\"Distribusi label:\", dict(Counter(y)))\n", + "print(\"Jumlah fitur:\", X.shape[1])\n", + "\n", + "cv = build_cv(y)\n", + "grid_search = build_grid_search(cv)\n", + "\n", + "grid_search.fit(X, y)\n", + "\n", + "best_model = grid_search.best_estimator_\n", + "\n", + "print(\"Best params:\", grid_search.best_params_)\n", + "print(\"Best CV f1_macro:\", grid_search.best_score_)\n", + "print(\"Urutan kelas:\", list(best_model.classes_))" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "6b93e357-b6b5-474a-a020-fc7c925d172a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Urutan label: ['PD', 'TPD']\n", + "[[29 14]\n", + " [15 28]]\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjUAAAHZCAYAAAB+e8r8AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAARdxJREFUeJzt3QmcTXX/wPHvGctgmLFlH0uRPVtSdilbj6yPHlREuyUU6SlFmx4Rzx/x9FQk2VKWKFFiCBWRFFNkDY8ihmHGMvN/fX899z4zY8bcO86de+ecz9vrvMY998y55557557v/X6/v3Os5OTkZAEAAMjhwoK9AQAAAHYgqAEAAI5AUAMAAByBoAYAADgCQQ0AAHAEghoAAOAIBDUAAMARCGoAAIAjENQAAABHIKgB/PTzzz9LmzZtJCoqSizLksWLF9u6D/ft22fWO3PmTF6b/2rZsqWZcrq+fftKxYoVXb0PgEAiqEGOtGfPHnnooYfk2muvlXz58klkZKQ0adJE/vnPf8q5c+cC+th9+vSR77//Xl566SV599135cYbbxSn0IOuBlS6P9PbjxrQ6f06jR8/3u/1Hz58WEaPHi3btm2TYPI8h/vvvz/d+59++mnvMr///rvkJOfPnzd/B/Xq1TOvY+HChaVmzZry4IMPyq5du8wyd955pxQoUEBOnz6d4Xp69+4tefPmlePHjzt+n8E5CGqQ4yxfvlxq164tCxYskI4dO8rkyZNl7NixUr58eRk+fLg89thjAXtsPdBv3LhR+vfvLwMHDpS7775bypUrZ+tjVKhQwTzOPffcI8GQO3duOXv2rHz00UeX3ffee++ZIDKrNKgZM2aM30HNypUrzWQnfR4ffPCBCQLSmjt37lU9z2Dq1q2bPP7441KrVi155ZVXzP5u3ry5fPLJJ7Jp0yZvwKLvsUWLFqW7Dn39lyxZIu3atZNixYo5fp/BOQhqkKPs3btX/va3v5kD/48//mi+kT7wwAMyYMAA86Gq8/RbaaD89ttv5qd++w0U/aarB4dcuXJJMISHh0vr1q3N/kxrzpw5cscdd2TbtujBVWnGQCc76QE7Li7OHOxT2rBhg3mfZefztMs333wjy5Ytk+eff95kER999FET5E+bNs2UNfVLgCdTU6hQIfN6pkcDmvj4eBP8OH2fwVkIapCjjBs3Ts6cOSNvvfWWlC5d+rL7K1eunCpTc/HiRXnhhRfkuuuuMwdr7Wf4+9//LomJial+T+f/5S9/kfXr18tNN91kggotbc2aNcu7jJZNNJhSmhHS4MPTH5FRr4T+ji6X0qpVq6Rp06YmMCpYsKBUrVrVbFNmPTWrV6+WZs2aSUREhPndTp06yc6dO9N9vN27d5tt0uW09+e+++7zBgi+6NWrlzlwnTx5MtUBU8tPel9aJ06ckCeeeMJk0PQ5admjffv28t1333mXWbNmjTRs2ND8X7fHU6rwPE/tF9HswpYtW0xmQcsjnv2Stp9ES4D6GqV9/m3btpUiRYqYjFBmypYtax4n7YFds1H6PHRb0vP+++9LgwYNJH/+/FK8eHGTrfv1118vW057rXQdup36M6OsSFJSkkyaNMkE47psyZIlTWn1jz/+kKyUZZWWYtPSINmTddFt79q1q3z++edy7Nixy5bVfaJBjwY/duwzILsQ1CBH0ZKIBhuNGzf2aXmt/z/77LNSv359mThxorRo0cKUqjTbk5YGAt27d5fbb79dJkyYYA6OGhj88MMP5n49COg6VM+ePc03YT0Y+UPXpcGTBlX6bVofRw8cX3755RV/77PPPjMHbD0AaeAybNgw8+1YD14aBKXVo0cP0y+hz1X/r4GDliF8pc9VA44PP/zQO08PZNWqVTP7Mq1ffvnFHMT1ub322msm6NO+I93fngCjevXq5jkr7e/Q/aeTHiQ9tH9Dg6G6deuafduqVat0t08zdNdcc40Jbi5dumTm/etf/zIlKi1HlilTxqfnqQGavqc0UPYEwRq0pBe4Kd2Puj81QNB9q1lC3UcapKYMAHU7tAyk+1CX69y5swnkNm/efNk6NYDR/eXpCdPlNEjQ1/vChQviD0/Qrb+vz+VKNAujy2gZN22A+umnn0qXLl1M8HO1+wzIVslADnHq1Klkfct26tTJp+W3bdtmlr///vtTzX/iiSfM/NWrV3vnVahQwcyLiYnxzjt27FhyeHh48uOPP+6dt3fvXrPcq6++mmqdffr0MetI67nnnjPLe0ycONHc/u233zLcbs9jzJgxwzuvbt26ySVKlEg+fvy4d953332XHBYWlnzvvfde9nj9+vVLtc4uXbokFytWLMPHTPk8IiIizP+7d++e3Lp1a/P/S5cuJZcqVSp5zJgx6e6DhIQEs0za56H77/nnn/fO++abby57bh4tWrQw902fPj3d+3RK6dNPPzXLv/jii8m//PJLcsGCBZM7d+6c7Av9vQEDBiSfOHEiOW/evMnvvvuumb98+fJky7KS9+3b592Xntfq/Pnz5jWoVatW8rlz57zrWrZsmVnu2WefTfV6lS5dOvnkyZPeeStXrjTLpXyfrFu3zsx77733Um3fihUrLpuf3j5IKykpybsfS5YsmdyzZ8/kqVOnJu/fv/+yZS9evGi28ZZbbkk1X/e//r7u36vdZwgd586dM5+hdk4p/w5CBZka5Bhay1eaFvfFxx9/bH5qViMlbaL0NBynVKNGDVPe8dBMgJaGNAthF08vjvYsaNnBF0eOHDGNtZo1Klq0qHf+DTfcYLJKnueZ0sMPP5zqtj4vzYJ49qEv9Ju3loyOHj1qSl/6M6Nv41raCwv78+NEMyf6WJ7S2rfffuvzY+p6NFPhCx1Wr1kOzf5oZklLN5qt8Ydm47RPxNM/pNkozQJ6Mh4paZZFM2Xap5KyIVb7SDSD5Xk/eV4vzSJp6c9DXyt9j6WkGQ5dRu/TEUOeSctbuv+++OILv56PZoY0y/Liiy+a56bPS/vN9PncddddqbJJmm3SjKU2vqfM9uk+0BKY9lVd7T5DaEhISJD8hYqZ95qdU6VKlcy6Q0nuYG8A4Cvt01BXGoaa0v79+82BVvtsUipVqpQJLvT+lHT0VHof4FnpbciIHljefPNNUxYbOXKkOXDoAVnLXp6gIL3noTRASEtLOnoQ06ZO7bXJ6Lno81D6XDz7MTMdOnQwAeT8+fPNQVr7YXRfplfu0gBNSyevv/66aRj1lIRUytEzmdGeDX8agnVYuQaIun16cC1RooT4SwM1HWl24MABU0LTvi1/XwcNarQfK+VyVapUuWy5tEGe9iidOnUqw+1Or9/Fl8BQh1frpAHW2rVrzWujZaY8efLI7NmzU5WgtKSq+077lw4dOiTr1q2TwYMHX7FR3dd9htBwXkerXTwr4TX6iOSyqeH+0nk5+uM7Zt2hNOqNoAY5hh6MtVdix44dfv1e2kbdjGT0If5n5j1rj5Hy4K60RyEmJsZ8A9dv9itWrDBBw6233mr6MOwa8XQ1zyXlwVEDrnfeecdkq7SXJyMvv/yyjBo1Svr162caszWjpEHakCFDfM5IqfR6OK5k69at3gO/9vBor5O/tKdJn6tmVrTXSXtmsovuGw1otAcmPZotvBraTK/ZGO3v0UZkDWy0L0iH7SvNCGlAplkXDWr0p75H0o56CqV9hquQO59YNgU1yVZoFnpCc6uADGgjqo7w0JR5ZjQdrgcN/Tac0n/+8x+ThrczXa6ZkJSpfY+02SClB3vN0GhDrQ5B15P4aXkno1KDZztjY2Mvu09PpqYjcFJmaeyk38g1cNDsWHrN1R4LFy40Tb06Kk2X09LQbbfddtk+8TXA9IVmp7RUpSUdbTzWbIGO0PKXBlLayKulNi0D6f7093XQeZ77PT/Tvu/S+10dlaelOm0S1v2VdqpTp47YQTM0Wq7UxuO0J8bTAEa/KGzfvt1kbDTD5BmldrX7DMhuBDXIUUaMGGEO4Fq+0eAkLQ14NNXuKZ+otCOUNJhQdp5TQw9OWkbQA4OHpv7TDuPVkSVp6UgflXaYecpv27qMZkxSBgl6INLsjud5BoIGKpp5mTJliinbXSkzlDYLpP0iaYc6e4Kv9AJAfz355JOm/KH7RV9THVLvyRz4S4ejP/fccybblBE9c7RmVaZPn57qMXTouw4t97yfUr5e+p5IOZRfg9iUNMOh2Tzdx2npqCJ/95MGUrpP0tL16BcBDb7TZn88WRkdJahlvMyyNP7sM4QYy3yzsGmSkET5CTmKBg/6bVJ7U7Sf5N577zXnxtC6rg5x1gOpNtQq/ZarB7k33njDfKjr8OKvv/7aHGz0W2ZGw4WzQrMTepDVYbDaj6DnhNETnl1//fWpeii0qVXLT3oA1G/0WjrRPhQ9K7EOC87Iq6++aoY633LLLeZsxno2WB26rM16VyoLXS3NKj3zzDM+ZdD0uWnmRJtGtRSkJRUdfp/29dN+Jg0MtF9Hg5xGjRqZhkN/aGZL95seVD1DzGfMmGHOZaMHWX97PPS9kllWRLMd//jHP8xz1PeSlro0sNYgWgOqoUOHepfVYdz6GutrqiU5DWb19dISkGcotNL1aLOzLq8BhWa49HE0ONH3sq5b+618pecF0uyavle0OVzLgBpY6nteh9ZrgJ+2NKn7Xl8z7U1SvgY1vuwzhBgr7M/JrnWFoNDcKiCTer5mRPTDXj+IdXSHNt1qA6ue9+X//u//vMtqU66en0XLEtrfoQfDp556SubNm2frPtZmWM3K6AnjNJukBxE9UHnO4Jpy27WJ9+233zbbPXXqVHOeFt2ulCNl0tJShPbf6OPoN2ptkL355pvN+W38DQgCQfsxdFSZNi3ryQ81kNOeoejo6FTL6QFb940eWHWElgYG2sjqDy2FaaCg1zbSZlgPPYjrY+t7wHM5ALtpwKw9UBpEaxCro600kNUm4ZRnmdbRQRqUaBZG3296LhsNutK7TpgGeBp4a4Cr+1GX1/eDntQvvZPoXYm+lzTroxkizV5pwKSNwBpAa4kwo0uIeAIZPfFk2sZ6ICexdFx3sDcCAAAERlxcnPnSFF7vUbFyhduyzuRLiZK49XUTQPs6ojI7UH4CAMANLMpPAAAAOQKZGgAA3MD678glu9YVgmgUBgAAjkCmBgAAVwizcSh2aOZECGoAAHADy/nlJ4KaINJT+OsJsfQkZHaePh4AkHPomVX0/Et6bbuMLmwL3xDUBJEGNGlPTgYAcKeDBw+as4sHjOX8Id0ENUGkGRqVt0Yf266cCuREB9aMD/YmAEFzOi5OKleK9h4TkHUENUHkKTlpQENQAzcLpTOSAsES8DYEi54aAADgBJbzy0+huVUAAAB+ovwEAIAbWJSfAACAE1iUnwAAAHIEyk8AALim/BRm37pCEI3CAAAgoMaOHSsNGzY05+IpUaKEdO7cWWJjY1Mtc/ToUbnnnnukVKlSEhERIfXr15cPPvjAr8chqAEAwA3CLHsnP6xdu1YGDBggmzZtklWrVsmFCxekTZs2Eh8f713m3nvvNYHO0qVL5fvvv5euXbtKjx49ZOvWrT4/DuUnAADcwApeo/CKFStS3Z45c6bJ2GzZskWaN29u5m3YsEGmTZsmN910k7n9zDPPyMSJE80y9erV8+lxyNQAAIAsiYuLSzUlJib69HunTp0yP4sWLeqd17hxY5k/f76cOHHCXPB53rx5kpCQIC1btvR5ewhqAABw03lqLJsmEXNR5qioKO+kvTOZ0YBlyJAh0qRJE6lVq5Z3/oIFC0xZqlixYhIeHi4PPfSQLFq0SCpXruzzU6T8BAAAsnxl8ZTXbtNgJDPaW7Njxw5Zv359qvmjRo2SkydPymeffSbFixeXxYsXm56adevWSe3atX3aHoIaAADcwLK/p0YDGn8uSDtw4EBZtmyZxMTESLly5bzz9+zZI1OmTDHBTs2aNc28OnXqmIBm6tSpMn36dJ/WT1ADAIAbWMG7TEJycrIMGjTIlJPWrFkjlSpVSnX/2bNnzc+wsNRBV65cuUy5ylcENQAAIKC05DRnzhxZsmSJOVeNnpNGaR9O/vz5pVq1aqZ3Rvtoxo8fb/pqtPykw781s+MrGoUBAHBT+cmyafKDDtXWEU86kql06dLeSUc7qTx58sjHH38s11xzjXTs2FFuuOEGmTVrlrzzzjvSoUMHnx+HTA0AAAgoLT9lpkqVKn6fQTgtghoAANzACl5PTXYhqAEAwA2s4J1ROLuE5lYBAAD4iUwNAABuYFF+AgAAjhBmY9koNAs9oblVAAAAfqL8BACAG1jOLz+RqQEAAI5ApgYAANdkasLsW1cIIqgBAMANLM5TAwAAkCOQqQEAwA0sGoUBAAByBDI1AAC4geX8nhqCGgAA3MCi/AQAAJAjkKkBAMANLMpPAADACSzKTwAAADkC5ScAAFzAsiwz2bQyCUWhOSYLAADAT2RqAABwAcsFmRqCGgAA3MD672TXukIQ5ScAAOAIZGoAAHABywXlJzI1AADAEcjUAADgApYLMjUENQAAuIDlgqCG8hMAAHAEMjUAALiARaYGAAAgZyBTAwCAG1jOP/keQQ0AAC5gUX4CAADIGcjUAADgAjoK274h3RKSCGoAAHABS//Zdn6Z0IxqOE8NAABwBDI1AAC4gEWjMAAAQM5ApgYAADewOE8NAABwAsu+RuFkLmgJAAAQOJSfAABwAcvGTI19Q8PtxZBuAADgCAQ1AAC4KFNj2TT5Y+zYsdKwYUMpVKiQlChRQjp37iyxsbGXLbdx40a59dZbJSIiQiIjI6V58+Zy7tw5nx+HoAYAADeNfrJsmvywdu1aGTBggGzatElWrVolFy5ckDZt2kh8fHyqgKZdu3Zm/tdffy3ffPONDBw4UMLCfA9V6KkBAAABtWLFilS3Z86caTI2W7ZsMdkYNXToUBk8eLCMHDnSu1zVqlX9ehwyNQAAuIAVxPJTWqdOnTI/ixYtan4eO3ZMvvrqKxPoNG7cWEqWLCktWrSQ9evX+7VeghoAAFzACkBQExcXl2pKTEzMdDuSkpJkyJAh0qRJE6lVq5aZ98svv5ifo0ePlgceeMBkdurXry+tW7eWn3/+2efnSFADAACyJDo6WqKioryTNgRnRntrduzYIfPmzUsV6KiHHnpI7rvvPqlXr55MnDjRlJ/efvttn7eHnhoAAFzACsB5ag4ePGhGKXmEh4df8fe08XfZsmUSExMj5cqV884vXbq0+VmjRo1Uy1evXl0OHDjg83aRqQEAAFmiAU3KKaOgJjk52QQ0ixYtktWrV0ulSpVS3V+xYkUpU6bMZcO8f/rpJ6lQoYLP20OmBgAAF7CCeEZhLTnNmTNHlixZYs5Vc/ToUTNfS1b58+c36xs+fLg899xzUqdOHalbt6688847smvXLlm4cKHPj0NQAwCAG1jBu0r3tGnTzM+WLVummj9jxgzp27ev+b82DyckJJih3SdOnDDBjZ7T5rrrrvP5cQhqAABAQGn5yRd6jpqU56nxF0ENAAAuYHFBSwAAgJyBTA0AAC5guSBTQ1ADAIALWC4IajhPDQAAcAQyNQAAuIEVvCHd2YWgBgAAF7AoPwEAAOQMZGoAAHABMjUAAAA5BJkaAABcwBIbh3SHaKcwQQ0AAC5g0SgMAACQM5CpAQDADSznn6eGMwoDAABHIFMDAIALWC7oqSGogeMN7dtG/tKqjlSpUFISEi/I19t/kdFTlsju/ce8y1QsW1xeeKyL3Fz3WsmbJ7d8vnGnPDn+ffntxOmgbjtgly+/3S2T3/1Mvtt1QI7+HiezX31A7mhZJ91lh46dKzM//FJeHtpNHunVihfBISwXBDWOLj/t27fP7Pht27aZ22vWrDG3T548GexNQzZqXL+yvPl+jLTpN166DpwieXLnkg8nD5QC+fKa+/Xnh1MGSLIkS6dHJkv7+ydK3jy5ZO5rD4XsHy7gr7PnEqXW9WXl1RF3XXG5ZV98J5u/3yelr4liJyPHyRFBzcaNGyVXrlxyxx13XNV6GjduLEeOHJGoqIz/WCtWrOiNZiMiIqR+/fry/vvve+8fPXq09/7cuXNL8eLFpXnz5jJp0iRJTEy8qu1DYPx18Osyd9lXsuuXo7Lj51/l0TGzJbp0UalbPdrc36jOtVK+dDEZMGa2/LjnsJkeHf2u1KteXpo3vJ6XBY5we5Oa8swjHU3WMiOHj500Gco3XugruXPnytbtQ+BZlr1TKMoRQc1bb70lgwYNkpiYGDl8+HCW15M3b14pVapUpt++n3/+eRP8bN26VRo2bCh33XWXbNiwwXt/zZo1zf0HDhyQL774Qv7617/K2LFjTdB0+jTlilAXWTCf+flH3FnzMzxvbklOTpbE8xe9yyScvyhJSclyc53rgradQHZKSkqSh5+bJYPubi3VryvNzkeOFPJBzZkzZ2T+/PnyyCOPmEzNzJkzU93/xx9/SO/eveWaa66R/PnzS5UqVWTGjBnprsvX8lOhQoVM8HP99dfL1KlTzXo/+ugj7/2aodH7y5QpI7Vr1zYB19q1a2XHjh3yj3/8w6ZnjkDQ13/ssO6yadse2bnniJn3zff75GzCeRk9qJPkD89jylHaX6PfVEsVj+SFgCtMemeV5M4VJg/9rWWwNwUBYpkMi2XTFJovU8gHNQsWLJBq1apJ1apV5e6775a3337bfKv2GDVqlPz444/yySefyM6dO2XatGmmJGQXDWDy5Mkj58+fv+Jyuo3t27eXDz/8MMNltDwVFxeXakL2Gj+ih/kW2v/p/wW+x0+ekb4j35J2zWrJoZgJsv+LVyWqUH7ZtvOAydYATqfv9X/NWyNTn7ubPjIns2wsPYVoUJM7J5SeNJhR7dq1k1OnTpmsSMuWf36b0BJQvXr15MYbb/T2xNhFA5kJEyaYx7z11lszXV4Dm5UrV2Z4v5aoxowZY9v2wT/jhv9V2jarJR0enGR6B1L64qtdUr/LGCkaFSEXLyVJ3JlzsmvFy7Jv5RZ2Mxxv49Y98tsfZ6R2x2e98y5dSpJn/vmhTJv3hWxf+nxQtw9wRFATGxsrX3/9tSxatMibNdH+Fg10PEGNlqW6desm3377rbRp00Y6d+5seluuxpNPPinPPPOMJCQkSMGCBeWVV17xqUlZM0hX6td56qmnZNiwYd7bmqmJjv6zWRWBpQGNDl/t+PA/5cDh4xkud+JUvPnZ7Mbr5ZoiBeWTdd/z0sDx7urQUFrcVDXVvO6Dp0qP9jdJ7443B227YC/LBUO6Qzqo0eDl4sWLpnclZeAQHh4uU6ZMMaOYtOSzf/9++fjjj2XVqlXSunVrGTBggIwfPz7Ljzt8+HDp27evCWhKlizp84un5a9KlSpleL9ut07IXuOf7CHd294ovZ54Q86cTZASxQqZ+XFnEsx5a1SvjjfLT3uPyu9/nJGbbqhk+m5en/tFqnPZADnZmbOJsvfgb97b+w8fl+9jD0nhqAISXaqoFC1cMNXy2lNWslikVKlYMghbi0CwbBy1FKIxTegGNRrMzJo1y5R/NAOTkmZj5s6dKw8//LC5rU3Cffr0MVOzZs1MUHI1QY325FSuXNmv39m1a5esWLHCZGMQWvp3b25+Lv/XkFTzHx3zrhnqrapUKCHPDrhTikQWkAOHT8iEGZ/K63NWB2V7gUDYtnO/dHz4/7y3n574Z/9fzzsayeuj72GnwxFCNqhZtmyZGdnUv3//y84ro+UmzeJoUPPss89KgwYNzDBrbcTV36tevXrAA66jR4+aIZDHjx83o6pefPFFqVu3rgmoEFqKNByY6TJjpiw1E+BUTRtcL398M8Xn5emjcZ6wMMtMdki2aT2uCWo0aLntttvSPVGeBjXjxo2T7du3m3PPaHZEzx6sQ681UzNv3ryAbtsPP/wgpUuXNicE1O2rUaOG2Qbt76G8BABAcFjJKcdHI1tpo7AGReG1HxAr15+n7AfcyJ8MAuDEY0HJYlFmpG1kZGTAjjVVH/9QcoVH2LLOS4nxEjuha8C22XGZGgAAYB/LBaOfQv7kewAAAL4gUwMAgAtYLhjSTaYGAAA4ApkaAABcwHJBTw1BDQAALmC5IKih/AQAAByBTA0AAC5guaBRmKAGAAAXsMTG8pOEZlRD+QkAADgCmRoAAFzAckH5iUwNAABwBDI1AAC4gOWCId0ENQAAuIBF+QkAACBnIFMDAIALWC4oP9EoDAAAAmrs2LHSsGFDKVSokJQoUUI6d+4ssbGx6S6bnJws7du3N4HT4sWL/XocghoAAFzUU2PZNPlj7dq1MmDAANm0aZOsWrVKLly4IG3atJH4+PjLlp00aVKWM0GUnwAAcAEriOWnFStWpLo9c+ZMk7HZsmWLNG/e3Dt/27ZtMmHCBNm8ebOULl3a7+0iqAEAAFkSFxeX6nZ4eLiZMnPq1Cnzs2jRot55Z8+elV69esnUqVOlVKlSWdoeyk8AALiBZWPp6b+JmujoaImKivJO2juTmaSkJBkyZIg0adJEatWq5Z0/dOhQady4sXTq1CnLT5FMDQAAyJKDBw9KZGSk97YvWRrtrdmxY4esX7/eO2/p0qWyevVq2bp1q1wNghoAAFzACkBPjQY0KYOazAwcOFCWLVsmMTExUq5cOe98DWj27NkjhQsXTrV8t27dpFmzZrJmzRqf1k9QAwCAC1hBPKOwDtMeNGiQLFq0yAQolSpVSnX/yJEj5f777081r3bt2jJx4kTp2LGjz49DUAMAAAJKS05z5syRJUuWmHPVHD161MzXPpz8+fObxuD0moPLly9/WQB0JQQ1AAC4gBXEId3Tpk0zP1u2bJlq/owZM6Rv375iF4IaAABcwApy+clfWfkdhnQDAABHIFMDAIALWFzQEgAAIGcgUwMAgAtYLsjUENQAAOACVhAbhbMLjcIAAMARyNQAAOAClgvKT2RqAACAI5CpAQDABSwX9NQQ1AAA4AIW5ScAAICcgUwNAAAuYNlYNgrR6hNBDQAAbhBmWWaya12hiNFPAADAESg/AQDgApYLRj+RqQEAAI5ApgYAABewXDCkm6AGAAAXCLP+nOxaVyii/AQAAByBTA0AAG5g2Vg2IlMDAAAQOGRqAABwAcsFQ7oJagAAcAHrv//sWlcoolEYAAA4ApkaAABcIMwFQ7oJagAAcAHLBSffo/wEAAAcgUwNAAAuYLlg9BOZGgAA4AhkagAAcIEwyzKTXesKRQQ1AAC4gOWC8pNPQU1cXJxERkZ6/38lnuUAAABCLqgpUqSIHDlyREqUKCGFCxdOdyhXcnKymX/p0qVAbCcAALgKlguGdPsU1KxevVqKFi1q/v/FF18EepsAAAACE9S0aNEi3f8DAICcwaKn5k/bt2/3eafdcMMNAXtBAABA1oQx+ulPdevWNfUz7Zu5EnpqAABASJef9u7dG/gtAQAAAWP9d7JrXTk2qKlQoULgtwQAACA7L5PwzjvvyPLly723R4wYYYZ5N27cWPbv33812wIAAAI8pNuyaXJEUPPyyy9L/vz5zf83btwoU6ZMkXHjxknx4sVl6NChgdhGAABwlcIseydHXCbh4MGDUrlyZfP/xYsXS/fu3eXBBx+UJk2aSMuWLQOxjQAAAPZnagoWLCjHjx83/1+5cqXcfvvt5v/58uWTc+fO+bs6AACQDSwXlJ/8ztRoEHP//fdLvXr15KeffpIOHTqY+T/88AMNxQAAhDArNGOR4GVqpk6dKrfccov89ttv8sEHH0ixYsXM/C1btkivXr0CsY0AAAD2BzU60kmbg5csWSLt2rXzzh8zZox06tTJ39UBAACHl5/Gjh0rDRs2lEKFCpmLY3fu3FliY2O99584cUIGDRokVatWNYORypcvL4MHD5ZTp04FNqhJ6/Tp0/LGG29Io0aNpE6dOle7OgAA4DBr166VAQMGyKZNm2TVqlVy4cIFadOmjcTHx5v7Dx8+bKbx48fLjh07ZObMmbJixQrp379/YHtqPGJiYuStt94yJagyZcpI165dTQYHAACEnjAbh2L7ux4NUFLSoEUzNtq60rx5c6lVq5aJJzyuu+46eemll+Tuu++WixcvSu7cue0Pao4ePWo2RIOZuLg46dGjhyQmJpqh3TVq1PBnVQAAIBtZNo5a8qxHY4GUwsPDzZQZT1mpaNGiV1wmMjLS54DGr/JTx44dTa1Lr9g9adIkkyaaPHmyzw8EAACcJTo6WqKioryT9s5kJikpSYYMGWLOb6cZmvT8/vvv8sILL5jz4PnD5/Dnk08+MU07jzzyiFSpUsWvBwEAAM67oOXBgwdNNsXDlyyN9tZo38z69evTvV+zP3fccYepAI0ePdqv7fI5U6MPrk3BDRo0ME3B2j+jkRQAAHCnyMjIVFNmQc3AgQNl2bJl8sUXX0i5cuUuu1/jDB1ZraOkFi1aJHny5AlMUHPzzTfLv//9bzly5Ig89NBDMm/ePNMgrGkk7WTWDQEAAKEpzLJsnfyRnJxsAhoNVFavXi2VKlVKN0OjI6Ly5s0rS5cuNVcq8Ps5+vsLERER0q9fP5O5+f777+Xxxx+XV155xXQx33nnnX5vAAAACDzLsnfyh5acZs+eLXPmzDFZGB14pJPn8kqegEaHeHsGI3mWuXTpUvacp0Ybh/UK3YcOHZK5c+dezaoAAIBDTZs2zYxm0gtfly5d2jvNnz/f3P/tt9/KV199ZZIletHslMto307Az1OTUq5cuczZAXUCAADuGNLtT/npSjTYyWyZbAtqAABAaLOyUDa60rpC0VVfJgEAACAUkKkBAMAFwrIwaulK6wpFZGoAAIB7MjU6XtxXDOsGACD0WC7oqfEpqPF1VJN2Q/sznhwAADh/9FNIBTV61mAAAADHNgonJCRk6TTGSO2HT8ZKoRQXBAPcpkibl4O9CUDQJF9MyLYm2jAb1xWK/N4uLS/p5cDLli0rBQsWlF9++cXMHzVqlDm1MQAAQI4Ial566SWZOXOmuTyCXnTKo1atWvLmm2/avX0AAMDGnhrLpskRQc2sWbPkjTfekN69e5vLI3jUqVNHdu3aZff2AQAAG1iWnl/GnilEYxr/g5pff/3VXGwqvWbiCxcu2LVdAAAAgQ1qatSoIevWrbts/sKFC6VevXr+rg4AAGSDMMveyRGjn5599lnp06ePydhodubDDz+U2NhYU5ZatmxZYLYSAADA7kxNp06d5KOPPpLPPvtMIiIiTJCzc+dOM+/222/3d3UAACAbWC5oFM7SeWqaNWsmq1atsn9rAABAQITZWDZyTPnJY/PmzSZD4+mzadCggZ3bBQAAENig5tChQ9KzZ0/58ssvpXDhwmbeyZMnpXHjxjJv3jwpV66cv6sEAAABZrnggpZ+99Tcf//9Zui2ZmlOnDhhJv2/Ng3rfQAAIPSEWZatkyMyNWvXrpUNGzZI1apVvfP0/5MnTza9NgAAADkiqImOjk73JHt6TagyZcrYtV0AAMBGYVzQ8nKvvvqqDBo0yDQKe+j/H3vsMRk/fjxvQAAAELqZmiJFiqQakx4fHy+NGjWS3Ln//PWLFy+a//fr1086d+4cuK0FAABZYrmgUdinoGbSpEmB3xIAABAwYWJfg6+uK8cGNXpZBAAAgFCW5ZPvqYSEBDl//nyqeZGRkVe7TQAAwGaWC8pPfp+nRvtpBg4cKCVKlDDXftJ+m5QTAABAjghqRowYIatXr5Zp06ZJeHi4vPnmmzJmzBgznFuv1A0AAEL32k9hNk2OKD/p1bg1eGnZsqXcd9995oR7lStXlgoVKsh7770nvXv3DsyWAgCAqyoZhdlUN3JM+Ukvi3Dttdd6+2f0tmratKnExMTYv4UAAACBCGo0oNm7d6/5f7Vq1WTBggXeDI7nApcAACA0G4UtmyZHBDVacvruu+/M/0eOHClTp06VfPnyydChQ2X48OGB2EYAAHCVwuipuZwGLx633Xab7Nq1S7Zs2WL6am644QbedAAAIGdkatLSBuGuXbtK0aJF5cEHH7RnqwAAgK0sm/85MqjxOH78uLz11lt2rQ4AACD7zigMAAByhjAbzy/jmPPUAACAnCfMBUGNbeUnAACAHJGp0WbgKzl58qQd2wMAAALAsiwz2bWuHB3UREVFZXr/vffea8c2AQAABC6omTFjhv9rBwAAISHMBT01NAoDAOAClo2XNwjR6hONwgAAwBnI1AAA4AJhlmUmu9YVihjSDQCAC4QF8YKWY8eOlYYNG0qhQoWkRIkS0rlzZ4mNjU21TEJCggwYMECKFSsmBQsWlG7dusl//vMf/56jf5sFAADgn7Vr15qAZdOmTbJq1Sq5cOGCtGnTRuLj41NdMPujjz6S999/3yx/+PDhTE8nkxblJwAA3MCyscHXz/WsWLEi1e2ZM2eajM2WLVukefPmcurUKXP9yDlz5sitt97qHXVdvXp1EwjdfPPNPj0OmRoAAJCtNIhRRYsWNT81uNHszW233eZdplq1alK+fHnZuHGjz+slUwMAgAuEiWUmu9al4uLiUs0PDw8305UkJSXJkCFDpEmTJlKrVi0z7+jRo5I3b14pXLhwqmVLlixp7vN9uwAAgGvOU2PZNKno6GhzRQHPpA3BmdHemh07dsi8efNsf45kagAAQJYcPHhQIiMjvbczy9IMHDhQli1bJjExMVKuXDnv/FKlSsn58+fNdSRTZmt09JPe5ysyNQAAuEBYAIZ0a0CTcsooqElOTjYBzaJFi2T16tVSqVKlVPc3aNBA8uTJI59//rl3ng75PnDggNxyyy0+P0cyNQAAIKC05KQjm5YsWWLOVePpk9GSVf78+c3P/v37y7Bhw0zzsAZIgwYNMgGNryOfFEENAAAuEBbEMwpPmzbN/GzZsmWq+Tpsu2/fvub/EydOlLCwMHPSvcTERGnbtq28/vrrfj0OQQ0AAC5gBfGCllp+yky+fPlk6tSpZsoqemoAAIAjkKkBAMAt56mx7D1PTaghUwMAAByBTA0AAC5gBbGnJrsQ1AAA4AJhNpZnQrXME6rbBQAA4BcyNQAAuIBlWWaya12hiKAGAAAXsP472bWuUET5CQAAOAKZGgAAXCAsiJdJyC5kagAAgCOQqQEAwCUscTaCGgAAXMBywcn3KD8BAABHIFMDAIALWC44Tw2ZGgAA4AhkagAAcIEwF1z7iaAGAAAXsCg/AQAA5AxkagAAcAHLBdd+IqgBAMAFLMpPAAAAOQOZGgAAXCDMBaOfQnW7AAAA/EKmBgAAF7Bc0FNDUAMAgAtYLhj9RPkJAAA4ApkaAABcwLL+nOxaVygiUwMAAByBTA0AAC4QJpaZ7FpXKCKoAQDABSzKTwAAADkDmRoAAFzA+u8/u9YVimgUBgAAjkCmBgAAF7Bc0FNDUAMAgAtYNo5+ovwEAAAQQGRqAABwAYvyEwAAcALLBUENo58AAIAjUH4CAMAFLM5TAwAAkDOQqQEAwAXCrD8nu9YVinJ0ULNv3z6pVKmSbN26VerWrStr1qyRVq1ayR9//CGFCxcO9uYhRHy1bY/8a95q+T72kBw7HidvvNRP2jar7b3/8ZfnyMIV36T6nRY3VZNZ4x8KwtYC9hv6t1vkL02qSpXoYpJw/qJ8/eMhGf3mF7L70AnvMiWKRMjzD9wqLetXkoIF8srugydkwtwv5aP1sbwkDmFRfsoeGzdulFy5cskdd9xxVetp3LixHDlyRKKioi67TwMey7KuOOkyM2fO9N4OCwuTcuXKyX333SfHjh3zrivl70REREiVKlWkb9++smXLlqvafgTG2YTzUv26svLC0G4ZLtOiUTX5ZtEY7zT5uXt4OeAYjWuXlzeXbpE2j70jXUfOlTy5csmHY3tKgXx5vMtMG9FRKpcrJr2ee1+aPPimfPRlrMx4uovUvq5kULcdyHGjn9566y0ZNGiQxMTEyOHDh7O8nrx580qpUqVMsJFRwOOZevToIe3atUs1T5dRkZGR5vahQ4fk3//+t3zyySdyzz2pD3IzZswwy/zwww8ydepUOXPmjDRq1EhmzZqV5e1HYLS6uboMf6CDtGt+Q4bLhOfJLSWKRXqnqEIFeDngGH99er7MXfW97Nr/u+z45Zg8On6ZRJeMkrpVSnmXualGOfn3ks3ybewR2X/0pEyY86Wcik9ItQycMaTbsmnylx7jO3bsKGXKlDHH6cWLF6e6X4+jAwcONMmE/PnzS40aNWT69Ok5K6jRJzF//nx55JFHTKZGMyUpaSmpd+/ecs0115gnqVkRDSjS48nGnDx5MsOAxzPpusLDw1PN02WUrkNv645v3769DB48WD777DM5d+6cd31a3tJlKlasKG3atJGFCxea7dQXRLcZOcumbbul/p2jpFXvl+XpCe/LH6fig71JQMBERoSbn3+cTvDO05JUlxbVpXChfOaA1bVlDQnPm1vWbz/AKwFbxMfHS506dUwiID3Dhg2TFStWyOzZs2Xnzp0yZMgQc0xdunRpzglqFixYINWqVZOqVavK3XffLW+//bYkJyd77x81apT8+OOPJluiT3LatGlSvHjxbN1GDYCSkpLk4sWLV1xu6NChcvr0aVm1alW2bRuunpaeXvt7b5kz8REZ+XBH2bRtj/QZ/oZcupTE7oXjaMAy9uHbZNOOg7Jz32/e+fe9uEhy584lez8YJv9Z/qRMfKyd3DPmA9l7mC9pTmGl6Ku5+n/+0yTBiy++KF26dEn3/g0bNkifPn2kZcuWJmHw4IMPmiDo66+/zjmNwlp60mBGaTno1KlTsnbtWvOk1IEDB6RevXpy4403mtv6RLPTzz//bNJf+viFChW64rIanHkamNOTmJhoJo+4uDibtxZZcWfr+t7/V7uujFS/rrQ0+9tLsnHbbmna4Hp2Khxl/MB2Ur3iNdJ+2Lup5j/dp4VEFQyXTiPmyIm4s9Kh8fWmp6bDsHflxxTBD3KusACMfkp7HNMKiE5ZoS0gmpXp16+fqZRo9eWnn36SiRMn+r5dEkSxsbEmAuvZs6e5nTt3brnrrrtMoOOhZal58+aZ0U0jRowwkVygaWBVsGBBKVCggMkglSxZUt57771Mf8+TYUqvp0eNHTvWNDF7pujoaNu3HVevfJniUjQqQvYf+p3dCUcZN6CNtL25snQc8Z4c/v20d37F0oXlwc43yqAJyyVm2z7TdzNu9nrZ+tMRuf/OBkHdZoS26OjoVMc1Pc5l1eTJk00fjfbUaDuIJjq0VNW8efOckanR4EVLOhqRpQwMNMqbMmWK2UGartq/f798/PHHpqzTunVrGTBggIwfPz5g26UZmW+//daMfipdurQpP/lCy2NKh5mn56mnnjI1Qw+NcAlsQs+RYyflj7izpmEYcFJAc0eTqtLxidly4OipVPcVCP9zFFRS0v9K/+pSUrJYoXpCEoTEkO6DBw+awTUeWc3SeIKaTZs2mWxNhQoVTGOxHu81RrjttttCO6jRYEZHCk2YMME02qbUuXNnmTt3rjz88MPmtjYJa51Np2bNmsnw4cMDGtRoMFO5cmW/f2/SpEnmxc1o519NWg5ZF382Ufb9+r+sy8Ejx+WHn3+VwpEFpHChAjJp5qfSvsUNck3RSNl/+HcZO+0jqVi2uDS/6c9yIpDTjR/UVrq3qim9nlsoZ86dN+ekUXHxiea8NT8dPC57fj0hE4e0l1FvfC4n4s7JHY2vl1b1K8nfRi0I9uYjhC9oGRkZmSqoySodiPP3v/9dFi1a5D29yw033CDbtm0zx/uQD2qWLVtmRgn179//svPKdOvWzWRxNKh59tlnpUGDBlKzZk3Tj6K/V716dQk2HWF19OhRs01a8/vXv/5lhqdpoMaJ/0LL9tiD8rfH/tdt/8KUJeZn93YN5aXHu8uuPYflgxXfSNyZc1KyeKQ0a1hVHu/fwYz8AJygf8c/S0jLJ/zZv+jx6KsfmaHeFy8lSY+n58tz/VvJ3Od7SET+PLL31z/M/au+2ROkrYabXLhwwUyaVEhJz2GnA3V8FbRPbQ1aNPJK70R5GtSMGzdOtm/fbupqWrbR5lstA2mmRntsgk1PyKfy5csnZcuWlaZNm5r+oPr1/9d0itBwS73Ksj8m40azdyf8mREEnKpIm5czXeaXw39Inxc+zJbtQTBHP9nDyuIpXHbv3u29vXfvXpOJKVq0qJQvX15atGhhKjF6rNfykw4a0kTBa6+95vt2JaccP41spT01GtTtPvS7FLIhfQfkVBU6vRrsTQCCJvligiSue8EMUrGjlJPRsebTb/dJREF71h9/Jk7a1q/o1zZ7LmWUlraW6DnqtPqhSYyVK1fKiRMnTGCjw7r1dCkZDcBJi/w6AAAuECaWhNnUVKPr8peequVKeRQ9oW1GJ9f1FUENAAAuYAW5/JQdgn5GYQAAADuQqQEAwA0s56dqyNQAAABHIFMDAIALWAE4o3CoIagBAMANLPvOKByiMQ3lJwAA4AxkagAAcAHL+X3CBDUAALiC5fyohtFPAADAESg/AQDgApYLRj+RqQEAAI5ApgYAABewbBzSbdvQcJsR1AAA4AKW8/uEKT8BAABnIFMDAIAbWM5P1dAoDAAAHIFMDQAALmC5YEg3QQ0AAC5guWD0E+UnAADgCGRqAABwAcv5fcJkagAAgDOQqQEAwA0s56dqCGoAAHABywWjn2gUBgAAjkCmBgAAF7BcMKSboAYAABewnN9SQ/kJAAA4A5kaAADcwHJ+qoZGYQAA4AhkagAAcAHLBUO6CWoAAHABywWjnyg/AQAARyBTAwCAC1jO7xMmUwMAAJyBTA0AAG5gOT9VQ1ADAIALWC4Y/USjMAAAcAQyNQAAuIDlgiHdBDUAALiA5fyWGspPAADAGcjUAADgBpbzUzU0CgMAAEcgUwMAgAtYLhjSTVADAIAbWDaOWgrNmIbyEwAACLyYmBjp2LGjlClTRizLksWLF1+2zM6dO+XOO++UqKgoiYiIkIYNG8qBAwd8fgx6agAAcFGfsGXT5K/4+HipU6eOTJ06Nd379+zZI02bNpVq1arJmjVrZPv27TJq1CjJly+fz49B+QkAAARc+/btzZSRp59+Wjp06CDjxo3zzrvuuuv8egwyNQAAuIFlf6omLi4u1ZSYmJilTUtKSpLly5fL9ddfL23btpUSJUpIo0aN0i1RXQlBDQAALhr9ZNn0T0VHR5v+F880duzYLG3bsWPH5MyZM/LKK69Iu3btZOXKldKlSxfp2rWrrF271uf1UH4CAABZcvDgQYmMjPTeDg8Pz3KmRnXq1EmGDh1q/l+3bl3ZsGGDTJ8+XVq0aOHTeghqAABwASsAF7TUgCZlUJNVxYsXl9y5c0uNGjVSza9evbqsX7/e5/VQfgIAAEGVN29eM3w7NjY21fyffvpJKlSo4PN6yNQAAOACVpAv/aQ9M7t37/be3rt3r2zbtk2KFi0q5cuXl+HDh8tdd90lzZs3l1atWsmKFSvko48+MsO7fUVQAwCAG1jBjWo2b95sghWPYcOGmZ99+vSRmTNnmsZg7Z/RZuPBgwdL1apV5YMPPjDnrvEVQQ0AAAi4li1bSnJy8hWX6devn5myiqAGAAAXsLigJQAAcEz1ybJvXaGI0U8AAMARKD8BAOACVpBHP2UHMjUAAMARyNQAAOACVgDOKBxqCGoAAHAFy/EFKMpPAADAEcjUAADgApYLyk9kagAAgCOQqQEAwAUsx3fUENQAAOAKFuUnAACAnIHyEwAALmBxQUsAAOAIlvObahj9BAAAHIHyEwAALmA5P1FDpgYAADgDmRoAAFzAcsGQboIaAABcwHLB6CcahQEAgCOQqQEAwA0s53cKk6kBAACOQKYGAAAXsJyfqCGoAQDADSwXjH6i/AQAAByB8hMAAK5g2TgUOzRTNQQ1AAC4gEX5CQAAIGegpwYAADgCQQ0AAHAEemoAAHABywU9NQQ1AAC4gOWCC1oS1ARRcnKy+Xn69OlgbgYQdMkXE4K9CUDQJF9MTHVMQNYR1ASRJ5ipV71SMDcDABAix4SoqKiArd+i/IRAKlOmjBw8eFAKFSokVqgWKB0uLi5OoqOjzesQGRkZ7M0Bsh1/A8GnGRoNaPSYgKtDpiaIwsLCpFy5csHcBPyXBjQENXAz/gaCK5AZGg8uaAkAAJzBcv5lujlPDQAAcATKT3C18PBwee6558xPwI34G3APywVDuq1kxpABAODoZvCoqCj59dhJ23oHdZ1lSxSWU6dOhVQ/IpkaAABcwGJINwAAcALL+X3CNAoDAABnYPQTHKlv377SuXNn7+2WLVvKkCFDvLcrVqwokyZNsmXdQCjgPQ+fUzV2TX6KiYmRjh07mpMM6glnFy9enOGyDz/8sFnG389pghpk64euvkl1yps3r1SuXFmef/55uXjxYsAf+8MPP5QXXnjBlnX985//lJkzZ2Z4/5o1a7zPU6eSJUtKt27d5JdffkkVVHnuz58/v7ndo0cPWb16tS3biNDghvd8yueY3qTvbc8XC8+8fPnySY0aNeT111/3rkfX77k/V65cUqRIEWnUqJHZX9qMCvtGP1k2/fNXfHy81KlTR6ZOnXrF5RYtWiSbNm3K0hmWCWqQrdq1aydHjhyRn3/+WR5//HEZPXq0vPrqq+kue/78edset2jRouZyFHbQUQSFCxfOdLnY2Fg5fPiwvP/++/LDDz+YbyiXLl3y3q8f1rovdLlZs2aZdd52223y0ksv2bKdCA1Of89rwKPPzzOpGTNmeG9/88033mUfeOABM+/HH380QfyAAQNk7ty53vt1FI3ef+jQIdmwYYM8+OCD5m+jbt265m8JOVv79u3lxRdflC5dumS4zK+//iqDBg2S9957T/LkyeP3YxDUINvPiVGqVCmpUKGCPPLII+YgvnTp0lTpcz2oa4RetWpVM1+vy6QfgPqhqh/UnTp1kn379nnXqYHCsGHDzP3FihWTESNGXHa127Tlp7TefPNN8/uff/65ub1w4UKpXbu2yaLoOnU79VtGyu3MTIkSJaR06dLSvHlzefbZZ80H+e7du7336wFH90X58uXNMm+88YaMGjXKLKuBDpzB6e95DXj0+Xkmpev13L7mmmu8yxYoUMDMu/baa01wV6VKFe++UJql0fv176Z69erSv39/E9ycOXPGPEfYM/rJsmmyW1JSktxzzz0yfPhwqVmzZpbWQVCDoNIP0JTfTvUDVg/oq1atkmXLlsmFCxekbdu2JgBYt26dfPnll1KwYEHz7dfzexMmTDCp67ffflvWr18vJ06cMOlLX40bN05GjhwpK1eulNatW5tvij179pR+/frJzp07TTmpa9eulx00/H2evnwTf+yxx8zjLFmyJMuPhdDmlvd8VvZFRl8OevfubYKflJlOZO3cMnE2TumtMzExMcsvzT/+8Q/JnTu3DB48OMvr4Dw1CAr9sNQP808//dSkGj0iIiLMN0jtP1CzZ8820bvO81zJXFPb+k1QP3jbtGljGsmeeuop8yGspk+fbtbriyeffFLeffddWbt2rfebgX7Aa8+Drk+/XSv9BptVur7x48dL2bJlvd/EM6LfyvVDPOW3cjiDm97zmdHgRMtO27dvNyWmzFSrVs1cxfr48ePm7wP+yZs3r8mAVakUbeuu02A7Ojr1OvUM7ZqF89eWLVtMKfPbb7/1vu+zgqAG2Uq/ieofgn4b1Q/uXr16pfoD0A9Sz4e7+u6770zJJm1vQEJCguzZs8c0EOoHsjYUemikf+ONN2b6LVO/7Wp6ffPmzSYd7qGNbPrtVbdFvzHrQaR79+6mcdEfegV23YazZ8+adX7wwQepnltG9Heu5o8aocVN7/nMaGOwBmuandFm4KFDh5qSXGY8z4u/i6zJly+f7N2719aeLc/rkvY1yeolZzQreezYMVOOTxn8ah+aBvG+ftEjqEG2atWqlUybNs18iGsPgX4Yp6TfWlPSWnqDBg1M01haKWv1WdGsWTNZvny5LFiwwKTiPfTDVksBWsvX9PzkyZPl6aeflq+++koqVark1x+pNj7qN0tfGzb1m+hvv/3m1+MgtLnpPZ8ZLSPperXspH0zYWG+dUBoSUz/lrTXB1kPbPLlyxeyu097abSPKyUNsHX+fffd5/N6CGqQrfQDXIe1+qp+/foyf/58ExhkdH0R/XDUD19ttlWaRtdUpv7uldx0000ycOBA06ugB5onnnjCe59++2jSpImZtHFXU/Las6DNmb7Sg4Evo6RS0vSrftBzHhzncNN7PjPaVOzPvlD67X3OnDnmb8LXIAihSQP2lIMlNHu0bds2U3bXDE3aoFVHP2nZLLOyfUoENQhp+s1Oh7/q6A8dAq0lnf3795tzcOhoCL2tzbWvvPKKGUmhtffXXntNTp486dP6GzduLB9//LEZaqgf8jpaRA8W2vugKXg9sOhtzZ7oaAw7aY/A0aNHTVlC/7i1l0JT82PHjvX7gx/O4eT3vC/lDP2b0J/6fDZu3Cgvv/yyCYb0+SJn27x5s8lcengC5j59+lzx3F/+IKhBSNMhoHoWSm1u1CZGDQS04Vbr/55vsVpz1R4D/cPQb3I6gkPPg+DrCbuaNm1qUvIdOnQwaXhNgepjah1Xu/n1G6v2IuhBwE76bVgnTxPfzTffbA4sKf/o4T5Ofs9nRh9bs1CaNdLnqt/Q9TlqEBdKV4JG1uhpBvwZUZeVARNWcqDH7AEAAGQDCpQAAMARCGoAAIAjENQAAABHIKgBAACOQFADAAAcgaAGAAA4AkENAABwBIIaAEHXt2/fVJeG0JN06ZluPSpWrGhODGfHugE4F2cUBnDFgOCdd97xXodFr89y7733yt///vfLLsxoJ70kgD6eHfR6WpxjFHAHghoAV6QXP5wxY4YkJiaaawYNGDDABBxPPfVUquXOnz9vLvlgB73AnV30ukEA3IHyE4ArCg8PN9em0usBPfLII+Y6QUuXLvWWdV566SUpU6aM90q6Bw8elB49epgrlGtwohdmTHkNl0uXLpkL2en9elVevUhj2kxK2vJTWnrhT/19vVaWWrhwodSuXVvy589v1qnbGB8fb+6j/AS4B0ENAL9o4KBZGaVBRWxsrKxatUqWLVtmrjjetm1bKVSokKxbt06+/PJLKViwoMn2eH5HL5SoV+R9++23Zf369XLixAlZtGiRz48/btw4GTlypKxcudJc5FEv7NizZ09zUcedO3fKmjVrzIUgKTkB7kP5CYBPNEjQIObTTz+VQYMGyW+//SYREREma+IpO82ePVuSkpLMPL3SstLSlWZVNNho06aNafjV0pUGHmr69Olmnb7QK1e/++67snbtWqlZs6aZp0HNxYsXzfo0m6Q0awPAfQhqAFyRZmA026JZGA1YevXqJaNHjza9NRo8pOyj+e6772T37t0mU5NSQkKC7NmzR06dOmWCkEaNGv3vQyh3brnxxhszzaxohkdLSps3b5Zrr73WO79OnTomY6PbolkiDZy6d+8uRYoU4ZUFXIbyE4AratWqlWzbtk1+/vlnOXfunBkNpRka5fnpcebMGWnQoIFZPuX0008/mWDoajRr1sz04yxYsCDV/Fy5cpny1yeffCI1atSQyZMnm/6evXv38soCLkNQA+CKNHCpXLmyGc6d2TDu+vXrm+CnRIkS5ndSTjoKSafSpUvLV1995f0dLR1t2bIl01fhpptuMoHLyy+/LOPHj091n5a6mjRpImPGjJGtW7ea7JE/fToAnIGgBoBtevfuLcWLFzcjnrRRWLMl2kszePBgOXTokFnmsccek1deeUUWL14su3btkkcffVROnjzp0/obN25shpVr8OI5GZ8GSBroaFnqwIED5hw32u9TvXp1XlnAZeipAWCbAgUKSExMjGno1cbd06dPS9myZU3PS2RkpFnm8ccfN301ffr0kbCwMDNqqUuXLqbfxhdNmzaV5cuXS4cOHUzpSYdv62NqkBMXF2eahbX/pn379ryygMtYyYx7BAAADkD5CQAAOAJBDQAAcASCGgAA4AgENQAAwBEIagAAgCMQ1AAAAEcgqAEAAI5AUAMAAByBoAYAADgCQQ0AAHAEghoAAOAIBDUAAECc4P8BgnQY+rzykeAAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from sklearn.model_selection import cross_val_predict\n", + "from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay\n", + "import matplotlib.pyplot as plt\n", + "\n", + "labels = [\"PD\", \"TPD\"]\n", + "\n", + "y_pred = cross_val_predict(best_model, X, y, cv=cv, n_jobs=1)\n", + "\n", + "cm = confusion_matrix(y, y_pred, labels=labels)\n", + "\n", + "print(\"Urutan label:\", labels)\n", + "print(cm)\n", + "\n", + "disp = ConfusionMatrixDisplay(\n", + " confusion_matrix=cm,\n", + " display_labels=[\"PD\", \"TPD\"]\n", + ")\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 5))\n", + "disp.plot(cmap=\"Blues\", values_format=\"d\", ax=ax, colorbar=True)\n", + "\n", + "ax.set_title(\"Confusion Matrix Model SVM\")\n", + "ax.set_xlabel(\"Prediksi\")\n", + "ax.set_ylabel(\"Label Asli\")\n", + "ax.set_xticklabels([\"Prediksi PD\", \"Prediksi TPD\"])\n", + "ax.set_yticklabels([\"Asli PD\", \"Asli TPD\"])\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "db2c5b18-fb32-426a-b704-d21b0aae65cf", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (ConfiVoice)", + "language": "python", + "name": "confivoice" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.20" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ml/api_app.py b/ml/api_app.py new file mode 100644 index 0000000..0763917 --- /dev/null +++ b/ml/api_app.py @@ -0,0 +1,181 @@ +from pathlib import Path +import tempfile + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +from audio_utils_api import SUPPORTED_AUDIO_EXTENSIONS, cleanup_temp_files +from database import ( + authenticate_user, + create_user, + get_database_label, + init_db, + list_prediction_results, + save_prediction_result, +) +from predict_api import MODEL_NOT_FOUND_MESSAGE, get_model_info, predict_audio + + +app = FastAPI(title="ConfiVoice Prediction API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class PredictionSaveRequest(BaseModel): + user_id: int | None = None + student_name: str + student_gender: str | None = None + predicted_label: str | None = None + label: str | None = None + description: str | None = None + confidence: float = 0 + probability_pd: float = 0 + probability_tpd: float = 0 + is_valid_audio: bool = True + error_message: str | None = None + audio_quality: dict = Field(default_factory=dict) + voice_indicators: dict = Field(default_factory=dict) + + +class AuthRequest(BaseModel): + username: str + password: str + + +class RegisterRequest(AuthRequest): + full_name: str + + +@app.on_event("startup") +def startup(): + init_db() + + +@app.get("/") +def read_root(): + return { + "name": "ConfiVoice Prediction API", + "status": "ok", + "predict_endpoint": "/predict", + "predictions_endpoint": "/predictions", + "database": get_database_label(), + } + + +@app.get("/model") +def read_model_info(): + try: + return get_model_info() + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail=MODEL_NOT_FOUND_MESSAGE) from error + except Exception as error: + raise HTTPException(status_code=500, detail=str(error)) from error + + +@app.get("/predictions") +def read_predictions(limit: int = 200, user_id: int | None = None): + return { + "database": get_database_label(), + "data": list_prediction_results(limit=limit, user_id=user_id), + } + + +@app.post("/register") +def register(payload: RegisterRequest): + full_name = " ".join(payload.full_name.strip().split()) + username = payload.username.strip().lower() + password = payload.password + + if not full_name: + raise HTTPException(status_code=400, detail="Nama lengkap wajib diisi.") + if len(username) < 3: + raise HTTPException(status_code=400, detail="Username minimal 3 karakter.") + if len(password) < 6: + raise HTTPException(status_code=400, detail="Password minimal 6 karakter.") + + try: + user = create_user(full_name, username, password) + except Exception as error: + message = str(error).lower() + if "duplicate" in message or "unique" in message: + raise HTTPException(status_code=409, detail="Username sudah terdaftar.") from error + raise HTTPException(status_code=500, detail=str(error)) from error + + return {"status": "registered", "user": user} + + +@app.post("/login") +def login(payload: AuthRequest): + username = payload.username.strip().lower() + user = authenticate_user(username, payload.password) + if not user: + raise HTTPException(status_code=401, detail="Username atau password salah.") + return {"status": "logged_in", "user": user} + + +@app.post("/predictions") +def create_prediction(payload: PredictionSaveRequest): + student_name = payload.student_name.strip() + if not student_name: + raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") + + result = payload.model_dump() + prediction_id = save_prediction_result(student_name, result) + return { + "status": "saved", + "prediction_id": prediction_id, + "student_name": student_name, + } + + +@app.post("/save") +def save_prediction(payload: PredictionSaveRequest): + return create_prediction(payload) + + +@app.post("/predict") +async def predict( + file: UploadFile = File(...), + student_name: str = Form("Tanpa Nama"), + student_gender: str = Form(""), +): + suffix = Path(file.filename or "").suffix.lower() + if suffix not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)) + raise HTTPException( + status_code=400, + detail=f"Format audio tidak didukung. Format yang didukung: {allowed}", + ) + + temp_input = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + temp_input_path = Path(temp_input.name) + + try: + content = await file.read() + temp_input.write(content) + temp_input.close() + + if not content: + raise HTTPException(status_code=400, detail="File audio kosong.") + + clean_student_name = student_name.strip() or "Tanpa Nama" + result = predict_audio(temp_input_path) + result["student_name"] = clean_student_name + result["student_gender"] = student_gender.strip() or None + return result + except HTTPException: + raise + except FileNotFoundError as error: + raise HTTPException(status_code=404, detail=MODEL_NOT_FOUND_MESSAGE) from error + except Exception as error: + raise HTTPException(status_code=500, detail=str(error)) from error + finally: + temp_input.close() + cleanup_temp_files(temp_input_path) diff --git a/ml/app.py b/ml/app.py new file mode 100644 index 0000000..c95ad33 --- /dev/null +++ b/ml/app.py @@ -0,0 +1,312 @@ +from datetime import datetime +import tempfile +from pathlib import Path + +import joblib +import librosa +import numpy as np +import streamlit as st + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, SAMPLE_RATE, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" + +CONFIDENCE_THRESHOLD = 0.75 +MARGIN_THRESHOLD = 0.25 +MIN_RECORDING_DURATION_SECONDS = 2.0 +LOW_RMS_WARNING_THRESHOLD = 0.003 + +SUPPORTED_UPLOAD_TYPES = [extension.replace(".", "") for extension in sorted(SUPPORTED_AUDIO_EXTENSIONS)] + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +@st.cache_resource +def load_model(model_mtime): + """ + model_mtime menjadi cache key. + Jika model dilatih ulang, Streamlit otomatis memuat model terbaru. + """ + return joblib.load(MODEL_PATH) + + +def save_bytes_to_temp_file(audio_bytes, suffix): + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_audio: + temp_audio.write(audio_bytes) + return Path(temp_audio.name) + + +def save_uploaded_file(uploaded_file, suffix): + return save_bytes_to_temp_file(uploaded_file.getvalue(), suffix) + + +def validate_audio_quality(audio_path, min_duration=MIN_RECORDING_DURATION_SECONDS): + """ + Validasi dasar sebelum ekstraksi fitur. + Error dipakai untuk kasus file kosong/gagal dibaca/terlalu pendek. + Warning dipakai untuk audio yang masih bisa diproses tetapi kualitasnya lemah. + """ + try: + y, sr = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True) + except Exception as error: + raise ValueError(f"File audio gagal dibaca: {error}") from error + + if y.size == 0: + raise ValueError("File audio kosong atau tidak memiliki sinyal suara.") + + duration = librosa.get_duration(y=y, sr=sr) + if duration < min_duration: + raise ValueError( + f"Durasi audio terlalu pendek ({duration:.2f} detik). " + "Silakan rekam suara 3 sampai 5 detik dengan jelas." + ) + + rms = float(np.sqrt(np.mean(y**2))) + warning = None + if rms < LOW_RMS_WARNING_THRESHOLD: + warning = ( + f"Suara terdeteksi cukup pelan (RMS={rms:.5f}). " + "Jika hasil kurang tepat, rekam ulang dengan suara lebih jelas." + ) + + return { + "duration": duration, + "rms": rms, + "warning": warning, + } + + +def predict_audio_path(audio_path, validate_quality=True): + """ + Fungsi prediksi umum untuk upload dan rekaman. + + Urutan: + audio_path -> convert_to_wav -> validate -> extract_features -> predict_proba. + Label utama diambil dari probabilitas terbesar, bukan model.predict(). + """ + model = load_model(MODEL_PATH.stat().st_mtime) + audio_path = Path(audio_path) + extension = audio_path.suffix.lower() + + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(SUPPORTED_UPLOAD_TYPES).upper() + raise ValueError(f"Format file tidak didukung. Gunakan salah satu: {allowed}") + + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + + try: + convert_to_wav(audio_path, temp_wav_path) + quality_info = validate_audio_quality(temp_wav_path) if validate_quality else None + feature_vector = extract_features(temp_wav_path) + features = feature_vector.reshape(1, -1) + + expected_features = model.named_steps["scaler"].n_features_in_ + if features.shape[1] != expected_features: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {features.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_features}. " + "Jalankan ulang `python train_model.py`, lalu restart Streamlit." + ) + + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + predicted_label = max(class_probabilities, key=class_probabilities.get) + confidence = class_probabilities[predicted_label] + + probability_pd = class_probabilities.get(LABEL_PD, 0.0) + probability_tpd = class_probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + debug_info = { + "model_classes": list(model.classes_), + "raw_probabilities": probabilities.tolist(), + "feature_shape": features.shape, + "confidence": float(confidence), + "margin": float(margin), + "quality_info": quality_info, + } + finally: + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, confidence, class_probabilities, debug_info + + +def render_prediction_result(label, confidence, probabilities, debug_info): + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + margin = abs(probability_pd - probability_tpd) + + quality_info = debug_info.get("quality_info") + if quality_info and quality_info.get("warning"): + st.warning(quality_info["warning"]) + + st.subheader("Hasil Prediksi") + st.write(f"Prediksi: {label}") + st.write(f"Keterangan: {LABEL_DESCRIPTION[label]}") + st.write(f"Confidence: {confidence * 100:.2f}%") + st.write(f"Probabilitas PD: {probability_pd * 100:.2f}%") + st.write(f"Probabilitas TPD: {probability_tpd * 100:.2f}%") + st.write(f"Margin: {margin * 100:.2f}%") + + if confidence >= CONFIDENCE_THRESHOLD and margin >= MARGIN_THRESHOLD: + st.success(f"Hasil utama: {label} - {LABEL_DESCRIPTION[label]}") + else: + st.warning("Model belum cukup yakin, silakan rekam ulang atau tambah data training.") + + st.progress(float(probability_pd), text=f"PD: {probability_pd * 100:.2f}%") + st.progress(float(probability_tpd), text=f"TPD: {probability_tpd * 100:.2f}%") + + with st.expander("Debug prediksi"): + st.write("model.classes_") + st.json(debug_info["model_classes"]) + st.write("Probabilitas mentah dari predict_proba") + st.json(debug_info["raw_probabilities"]) + st.write(f"Fitur audio shape: {debug_info['feature_shape']}") + st.write(f"Confidence: {debug_info['confidence']:.6f}") + st.write(f"Margin probabilitas: {debug_info['margin']:.6f}") + if quality_info: + st.write(f"Durasi audio: {quality_info['duration']:.2f} detik") + st.write(f"RMS audio: {quality_info['rms']:.6f}") + + +def get_audio_recorder_input(): + """ + Menggunakan st.audio_input jika tersedia. + Jika belum tersedia, coba fallback ke streamlit-mic-recorder. + """ + if hasattr(st, "audio_input"): + return st.audio_input("Rekam suara") + + try: + from streamlit_mic_recorder import mic_recorder + except ImportError: + st.error( + "Versi Streamlit ini belum mendukung st.audio_input. " + "Install fallback recorder dengan perintah: pip install streamlit-mic-recorder" + ) + return None + + audio = mic_recorder( + start_prompt="Mulai Rekam", + stop_prompt="Berhenti Rekam", + just_once=False, + use_container_width=True, + key="mic_recorder", + ) + + if audio and audio.get("bytes"): + suffix = ".wav" + return { + "bytes": audio["bytes"], + "suffix": suffix, + "mime_type": "audio/wav", + } + + return None + + +def get_recording_bytes(recording): + if recording is None: + return None, ".wav", "audio/wav" + + if isinstance(recording, dict): + return recording["bytes"], recording.get("suffix", ".wav"), recording.get("mime_type", "audio/wav") + + suffix = Path(recording.name).suffix.lower() or ".wav" + return recording.getvalue(), suffix, recording.type or "audio/wav" + + +def save_recording_to_dataset(source_audio_path, label): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + target_dir = DATA_DIR / label + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / f"recorded_{label}_{timestamp}.wav" + + convert_to_wav(source_audio_path, target_path) + return target_path + + +st.set_page_config( + page_title="Klasifikasi Percaya Diri dari Suara", + layout="centered", +) + +st.title("Klasifikasi Percaya Diri dari Suara") +st.write("Input audio, ekstraksi fitur, prediksi SVM, lalu tampilkan PD atau TPD.") + +if not MODEL_PATH.exists(): + st.error("Model belum ditemukan. Jalankan `python train_model.py` terlebih dahulu.") + st.stop() + +upload_tab, record_tab = st.tabs(["Upload Audio", "Rekam Audio"]) + +with upload_tab: + uploaded_file = st.file_uploader( + "Pilih file audio", + type=SUPPORTED_UPLOAD_TYPES, + ) + + if uploaded_file is not None: + uploaded_extension = Path(uploaded_file.name).suffix.lower().replace(".", "") + st.audio(uploaded_file, format=f"audio/{uploaded_extension}") + + if st.button("Prediksi Upload"): + temp_input_path = save_uploaded_file(uploaded_file, Path(uploaded_file.name).suffix.lower()) + try: + with st.spinner("Mengekstraksi fitur dan memprediksi..."): + label, confidence, probabilities, debug_info = predict_audio_path(temp_input_path) + render_prediction_result(label, confidence, probabilities, debug_info) + except Exception as error: + st.error(f"Gagal memproses audio: {error}") + finally: + temp_input_path.unlink(missing_ok=True) + +with record_tab: + st.write( + "Silakan rekam suara selama 3-5 detik. Gunakan suara yang jelas, " + "tidak terlalu pelan, dan hindari noise ruangan." + ) + + recording = get_audio_recorder_input() + audio_bytes, suffix, mime_type = get_recording_bytes(recording) + + if audio_bytes: + st.audio(audio_bytes, format=mime_type) + + temp_recording_path = save_bytes_to_temp_file(audio_bytes, suffix) + st.session_state["latest_recording_path"] = str(temp_recording_path) + + if st.button("Prediksi Rekaman"): + try: + with st.spinner("Mengekstraksi fitur dan memprediksi rekaman..."): + label, confidence, probabilities, debug_info = predict_audio_path(temp_recording_path) + render_prediction_result(label, confidence, probabilities, debug_info) + except Exception as error: + st.error(f"Gagal memproses rekaman: {error}") + + st.divider() + st.subheader("Simpan Rekaman ke Dataset") + selected_label = st.selectbox( + "Label manual", + options=[LABEL_PD, LABEL_TPD], + format_func=lambda label: f"{label} - {LABEL_DESCRIPTION[label]}", + ) + + if st.button("Simpan ke Dataset"): + try: + saved_path = save_recording_to_dataset(temp_recording_path, selected_label) + st.success( + "Rekaman berhasil disimpan. Jalankan ulang train_model.py " + "untuk melatih ulang model." + ) + st.write(f"File: {saved_path}") + except Exception as error: + st.error(f"Gagal menyimpan rekaman ke dataset: {error}") diff --git a/ml/audio_utils.py b/ml/audio_utils.py new file mode 100644 index 0000000..749a743 --- /dev/null +++ b/ml/audio_utils.py @@ -0,0 +1,83 @@ +from pathlib import Path +import shutil +import subprocess + +import imageio_ffmpeg + + +TARGET_SAMPLE_RATE = 22050 +SUPPORTED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac"} + + +def configure_ffmpeg(): + """ + Mengambil path ffmpeg dari imageio-ffmpeg. + Jika ffmpeg gagal tersedia, error dibuat lebih mudah dipahami. + """ + try: + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception as error: + raise RuntimeError( + "ffmpeg belum tersedia. Jalankan `pip install imageio-ffmpeg` " + "atau install ffmpeg di sistem operasi." + ) from error + + +def convert_to_wav(input_path, output_path): + """ + Mengubah WAV, MP3, M4A, OGG, FLAC, WEBM, atau AAC menjadi WAV. + + Output selalu: + - WAV + - mono + - sample rate 22050 Hz + """ + input_path = Path(input_path) + output_path = Path(output_path) + extension = input_path.suffix.lower() + + if not input_path.exists(): + raise FileNotFoundError(f"File audio tidak ditemukan: {input_path}") + + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)) + raise ValueError(f"Format audio tidak didukung: {extension}. Format yang didukung: {allowed}") + + output_path.parent.mkdir(parents=True, exist_ok=True) + + if extension == ".wav": + if input_path.resolve() != output_path.resolve(): + shutil.copyfile(input_path, output_path) + return output_path + + command = [ + configure_ffmpeg(), + "-y", + "-i", + str(input_path), + "-vn", + "-ac", + "1", + "-ar", + str(TARGET_SAMPLE_RATE), + "-f", + "wav", + str(output_path), + ] + + try: + result = subprocess.run(command, capture_output=True, text=True, check=False) + except FileNotFoundError as error: + raise RuntimeError( + "ffmpeg tidak bisa dijalankan. Pastikan dependency `imageio-ffmpeg` " + "sudah terpasang atau install ffmpeg secara manual." + ) from error + + if result.returncode != 0: + error_message = result.stderr.strip() or "Tidak ada detail error dari ffmpeg." + raise RuntimeError(f"Konversi audio gagal: {error_message}") + + if not output_path.exists(): + raise RuntimeError("Konversi audio gagal: file WAV output tidak terbentuk.") + + return output_path diff --git a/ml/audio_utils_api.py b/ml/audio_utils_api.py new file mode 100644 index 0000000..82f1522 --- /dev/null +++ b/ml/audio_utils_api.py @@ -0,0 +1,99 @@ +from pathlib import Path +import shutil +import subprocess +import tempfile + +try: + import imageio_ffmpeg +except ImportError: + imageio_ffmpeg = None + + +TARGET_SAMPLE_RATE = 22050 +SUPPORTED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm", ".aac"} + + +def validate_audio_extension(file_path): + extension = Path(file_path).suffix.lower() + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)).upper().replace(".", "") + raise ValueError(f"Format audio tidak didukung: {extension}. Format yang didukung: {allowed}") + + +def create_temp_wav_path(): + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") + temp_path = Path(temp_file.name) + temp_file.close() + return temp_path + + +def configure_ffmpeg(): + if imageio_ffmpeg is None: + raise RuntimeError( + "ffmpeg belum tersedia. Jalankan `pip install imageio-ffmpeg` " + "di virtual environment backend." + ) + try: + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception as error: + raise RuntimeError( + "ffmpeg belum tersedia. Jalankan `pip install imageio-ffmpeg` " + "atau install ffmpeg di sistem operasi." + ) from error + + +def convert_audio_to_wav(input_path, output_path=None): + input_path = Path(input_path) + validate_audio_extension(input_path) + + if not input_path.exists(): + raise FileNotFoundError(f"File audio tidak ditemukan: {input_path}") + + if input_path.suffix.lower() == ".wav" and output_path is None: + return input_path + + output_path = Path(output_path) if output_path else create_temp_wav_path() + output_path.parent.mkdir(parents=True, exist_ok=True) + + if input_path.suffix.lower() == ".wav": + if input_path.resolve() != output_path.resolve(): + shutil.copyfile(input_path, output_path) + return output_path + + command = [ + configure_ffmpeg(), + "-y", + "-i", + str(input_path), + "-vn", + "-ac", + "1", + "-ar", + str(TARGET_SAMPLE_RATE), + "-f", + "wav", + str(output_path), + ] + + try: + result = subprocess.run(command, capture_output=True, text=True, check=False) + except FileNotFoundError as error: + raise RuntimeError( + "ffmpeg tidak bisa dijalankan. Pastikan dependency `imageio-ffmpeg` " + "sudah terpasang di virtual environment backend." + ) from error + + if result.returncode != 0: + error_message = result.stderr.strip() or "Tidak ada detail error dari ffmpeg." + raise RuntimeError(f"Konversi audio gagal: {error_message}") + + if not output_path.exists(): + raise RuntimeError("Konversi audio gagal: file WAV output tidak terbentuk.") + + return output_path + + +def cleanup_temp_files(*paths): + for path in paths: + if path: + Path(path).unlink(missing_ok=True) diff --git a/ml/data/a_pd.wav b/ml/data/a_pd.wav new file mode 100644 index 0000000..d6a31b4 Binary files /dev/null and b/ml/data/a_pd.wav differ diff --git a/ml/data/a_tpd.wav b/ml/data/a_tpd.wav new file mode 100644 index 0000000..bf1b4e3 Binary files /dev/null and b/ml/data/a_tpd.wav differ diff --git a/ml/data/b_pd.wav b/ml/data/b_pd.wav new file mode 100644 index 0000000..b6a8c53 Binary files /dev/null and b/ml/data/b_pd.wav differ diff --git a/ml/data/b_tpd.wav b/ml/data/b_tpd.wav new file mode 100644 index 0000000..885bde1 Binary files /dev/null and b/ml/data/b_tpd.wav differ diff --git a/ml/data/c_pd.wav b/ml/data/c_pd.wav new file mode 100644 index 0000000..7401584 Binary files /dev/null and b/ml/data/c_pd.wav differ diff --git a/ml/data/c_tpd.wav b/ml/data/c_tpd.wav new file mode 100644 index 0000000..e7d48d7 Binary files /dev/null and b/ml/data/c_tpd.wav differ diff --git a/ml/data/d_pd.wav b/ml/data/d_pd.wav new file mode 100644 index 0000000..9379369 Binary files /dev/null and b/ml/data/d_pd.wav differ diff --git a/ml/data/d_tpd.wav b/ml/data/d_tpd.wav new file mode 100644 index 0000000..0c642e3 Binary files /dev/null and b/ml/data/d_tpd.wav differ diff --git a/ml/data/e_pd.wav b/ml/data/e_pd.wav new file mode 100644 index 0000000..5df619d Binary files /dev/null and b/ml/data/e_pd.wav differ diff --git a/ml/data/e_tpd.wav b/ml/data/e_tpd.wav new file mode 100644 index 0000000..6625cee Binary files /dev/null and b/ml/data/e_tpd.wav differ diff --git a/ml/data/f_pd.wav b/ml/data/f_pd.wav new file mode 100644 index 0000000..9e3730c Binary files /dev/null and b/ml/data/f_pd.wav differ diff --git a/ml/data/f_tpd.wav b/ml/data/f_tpd.wav new file mode 100644 index 0000000..9023f84 Binary files /dev/null and b/ml/data/f_tpd.wav differ diff --git a/ml/data/g_pd.wav b/ml/data/g_pd.wav new file mode 100644 index 0000000..9972592 Binary files /dev/null and b/ml/data/g_pd.wav differ diff --git a/ml/data/g_tpd.wav b/ml/data/g_tpd.wav new file mode 100644 index 0000000..47e81c1 Binary files /dev/null and b/ml/data/g_tpd.wav differ diff --git a/ml/data/h_pd.wav b/ml/data/h_pd.wav new file mode 100644 index 0000000..2165615 Binary files /dev/null and b/ml/data/h_pd.wav differ diff --git a/ml/data/h_tpd.wav b/ml/data/h_tpd.wav new file mode 100644 index 0000000..e13647f Binary files /dev/null and b/ml/data/h_tpd.wav differ diff --git a/ml/data/i_pd.wav b/ml/data/i_pd.wav new file mode 100644 index 0000000..fb8b2ef Binary files /dev/null and b/ml/data/i_pd.wav differ diff --git a/ml/data/i_tpd.wav b/ml/data/i_tpd.wav new file mode 100644 index 0000000..792ef36 Binary files /dev/null and b/ml/data/i_tpd.wav differ diff --git a/ml/data/j_pd.wav b/ml/data/j_pd.wav new file mode 100644 index 0000000..0213daa Binary files /dev/null and b/ml/data/j_pd.wav differ diff --git a/ml/data/j_tpd.wav b/ml/data/j_tpd.wav new file mode 100644 index 0000000..9878a57 Binary files /dev/null and b/ml/data/j_tpd.wav differ diff --git a/ml/data/k_pd.wav b/ml/data/k_pd.wav new file mode 100644 index 0000000..f382106 Binary files /dev/null and b/ml/data/k_pd.wav differ diff --git a/ml/data/k_tpd.wav b/ml/data/k_tpd.wav new file mode 100644 index 0000000..d32802a Binary files /dev/null and b/ml/data/k_tpd.wav differ diff --git a/ml/data/l_pd.wav b/ml/data/l_pd.wav new file mode 100644 index 0000000..6b50382 Binary files /dev/null and b/ml/data/l_pd.wav differ diff --git a/ml/data/l_tpd.wav b/ml/data/l_tpd.wav new file mode 100644 index 0000000..94032bb Binary files /dev/null and b/ml/data/l_tpd.wav differ diff --git a/ml/data/m_pd.wav b/ml/data/m_pd.wav new file mode 100644 index 0000000..a0d1ec6 Binary files /dev/null and b/ml/data/m_pd.wav differ diff --git a/ml/data/m_tpd.wav b/ml/data/m_tpd.wav new file mode 100644 index 0000000..0bc04cd Binary files /dev/null and b/ml/data/m_tpd.wav differ diff --git a/ml/data/n_pd.wav b/ml/data/n_pd.wav new file mode 100644 index 0000000..c9e1b36 Binary files /dev/null and b/ml/data/n_pd.wav differ diff --git a/ml/data/n_tpd.wav b/ml/data/n_tpd.wav new file mode 100644 index 0000000..97d0341 Binary files /dev/null and b/ml/data/n_tpd.wav differ diff --git a/ml/data/o_pd.wav b/ml/data/o_pd.wav new file mode 100644 index 0000000..620dd29 Binary files /dev/null and b/ml/data/o_pd.wav differ diff --git a/ml/data/o_tpd.wav b/ml/data/o_tpd.wav new file mode 100644 index 0000000..649652b Binary files /dev/null and b/ml/data/o_tpd.wav differ diff --git a/ml/data/p10_pd.wav b/ml/data/p10_pd.wav new file mode 100644 index 0000000..64c9716 Binary files /dev/null and b/ml/data/p10_pd.wav differ diff --git a/ml/data/p10_tpd.wav b/ml/data/p10_tpd.wav new file mode 100644 index 0000000..4940481 Binary files /dev/null and b/ml/data/p10_tpd.wav differ diff --git a/ml/data/p11_pd.wav b/ml/data/p11_pd.wav new file mode 100644 index 0000000..a4fbaea Binary files /dev/null and b/ml/data/p11_pd.wav differ diff --git a/ml/data/p11_tpd.wav b/ml/data/p11_tpd.wav new file mode 100644 index 0000000..d1f8873 Binary files /dev/null and b/ml/data/p11_tpd.wav differ diff --git a/ml/data/p1_pd.wav b/ml/data/p1_pd.wav new file mode 100644 index 0000000..e73afb7 Binary files /dev/null and b/ml/data/p1_pd.wav differ diff --git a/ml/data/p1_tpd.wav b/ml/data/p1_tpd.wav new file mode 100644 index 0000000..0358164 Binary files /dev/null and b/ml/data/p1_tpd.wav differ diff --git a/ml/data/p2_pd.wav b/ml/data/p2_pd.wav new file mode 100644 index 0000000..089de9d Binary files /dev/null and b/ml/data/p2_pd.wav differ diff --git a/ml/data/p2_tpd.wav b/ml/data/p2_tpd.wav new file mode 100644 index 0000000..30dcf1a Binary files /dev/null and b/ml/data/p2_tpd.wav differ diff --git a/ml/data/p3_pd.wav b/ml/data/p3_pd.wav new file mode 100644 index 0000000..3f6d429 Binary files /dev/null and b/ml/data/p3_pd.wav differ diff --git a/ml/data/p3_tpd.wav b/ml/data/p3_tpd.wav new file mode 100644 index 0000000..305c258 Binary files /dev/null and b/ml/data/p3_tpd.wav differ diff --git a/ml/data/p4_pd.wav b/ml/data/p4_pd.wav new file mode 100644 index 0000000..4e2df06 Binary files /dev/null and b/ml/data/p4_pd.wav differ diff --git a/ml/data/p4_tpd.wav b/ml/data/p4_tpd.wav new file mode 100644 index 0000000..9935df8 Binary files /dev/null and b/ml/data/p4_tpd.wav differ diff --git a/ml/data/p5_pd.wav b/ml/data/p5_pd.wav new file mode 100644 index 0000000..648a71d Binary files /dev/null and b/ml/data/p5_pd.wav differ diff --git a/ml/data/p5_tpd.wav b/ml/data/p5_tpd.wav new file mode 100644 index 0000000..7475459 Binary files /dev/null and b/ml/data/p5_tpd.wav differ diff --git a/ml/data/p6_pd.wav b/ml/data/p6_pd.wav new file mode 100644 index 0000000..3ff7bdb Binary files /dev/null and b/ml/data/p6_pd.wav differ diff --git a/ml/data/p6_tpd.wav b/ml/data/p6_tpd.wav new file mode 100644 index 0000000..8a93b7d Binary files /dev/null and b/ml/data/p6_tpd.wav differ diff --git a/ml/data/p7_pd.wav b/ml/data/p7_pd.wav new file mode 100644 index 0000000..23a7e6d Binary files /dev/null and b/ml/data/p7_pd.wav differ diff --git a/ml/data/p7_tpd.wav b/ml/data/p7_tpd.wav new file mode 100644 index 0000000..c8c696b Binary files /dev/null and b/ml/data/p7_tpd.wav differ diff --git a/ml/data/p8_pd.wav b/ml/data/p8_pd.wav new file mode 100644 index 0000000..e14469c Binary files /dev/null and b/ml/data/p8_pd.wav differ diff --git a/ml/data/p8_tpd.wav b/ml/data/p8_tpd.wav new file mode 100644 index 0000000..327e25a Binary files /dev/null and b/ml/data/p8_tpd.wav differ diff --git a/ml/data/p9_pd.wav b/ml/data/p9_pd.wav new file mode 100644 index 0000000..ec3bb39 Binary files /dev/null and b/ml/data/p9_pd.wav differ diff --git a/ml/data/p9_tpd.wav b/ml/data/p9_tpd.wav new file mode 100644 index 0000000..bc22054 Binary files /dev/null and b/ml/data/p9_tpd.wav differ diff --git a/ml/data/p_pd.wav b/ml/data/p_pd.wav new file mode 100644 index 0000000..17fda8d Binary files /dev/null and b/ml/data/p_pd.wav differ diff --git a/ml/data/p_tpd.wav b/ml/data/p_tpd.wav new file mode 100644 index 0000000..764bd54 Binary files /dev/null and b/ml/data/p_tpd.wav differ diff --git a/ml/data/q_pd.wav b/ml/data/q_pd.wav new file mode 100644 index 0000000..225cc89 Binary files /dev/null and b/ml/data/q_pd.wav differ diff --git a/ml/data/q_tpd.wav b/ml/data/q_tpd.wav new file mode 100644 index 0000000..756e040 Binary files /dev/null and b/ml/data/q_tpd.wav differ diff --git a/ml/data/r_pd.wav b/ml/data/r_pd.wav new file mode 100644 index 0000000..d6da78a Binary files /dev/null and b/ml/data/r_pd.wav differ diff --git a/ml/data/r_tpd.wav b/ml/data/r_tpd.wav new file mode 100644 index 0000000..76591f1 Binary files /dev/null and b/ml/data/r_tpd.wav differ diff --git a/ml/data/s_pd.wav b/ml/data/s_pd.wav new file mode 100644 index 0000000..df1d17f Binary files /dev/null and b/ml/data/s_pd.wav differ diff --git a/ml/data/s_tpd.wav b/ml/data/s_tpd.wav new file mode 100644 index 0000000..a677c34 Binary files /dev/null and b/ml/data/s_tpd.wav differ diff --git a/ml/data/t_pd.wav b/ml/data/t_pd.wav new file mode 100644 index 0000000..c424841 Binary files /dev/null and b/ml/data/t_pd.wav differ diff --git a/ml/data/t_tpd.wav b/ml/data/t_tpd.wav new file mode 100644 index 0000000..0e93a22 Binary files /dev/null and b/ml/data/t_tpd.wav differ diff --git a/ml/data/u_pd.wav b/ml/data/u_pd.wav new file mode 100644 index 0000000..c5b0906 Binary files /dev/null and b/ml/data/u_pd.wav differ diff --git a/ml/data/u_tpd.wav b/ml/data/u_tpd.wav new file mode 100644 index 0000000..97f8256 Binary files /dev/null and b/ml/data/u_tpd.wav differ diff --git a/ml/data/v_pd.wav b/ml/data/v_pd.wav new file mode 100644 index 0000000..f774e24 Binary files /dev/null and b/ml/data/v_pd.wav differ diff --git a/ml/data/v_tpd.wav b/ml/data/v_tpd.wav new file mode 100644 index 0000000..c04c8dc Binary files /dev/null and b/ml/data/v_tpd.wav differ diff --git a/ml/data/w1_pd.wav b/ml/data/w1_pd.wav new file mode 100644 index 0000000..f4f24e7 Binary files /dev/null and b/ml/data/w1_pd.wav differ diff --git a/ml/data/w1_tpd.wav b/ml/data/w1_tpd.wav new file mode 100644 index 0000000..4a89555 Binary files /dev/null and b/ml/data/w1_tpd.wav differ diff --git a/ml/data/w2_pd.wav b/ml/data/w2_pd.wav new file mode 100644 index 0000000..b3fcb83 Binary files /dev/null and b/ml/data/w2_pd.wav differ diff --git a/ml/data/w2_tpd.wav b/ml/data/w2_tpd.wav new file mode 100644 index 0000000..4283462 Binary files /dev/null and b/ml/data/w2_tpd.wav differ diff --git a/ml/data/w3_pd.wav b/ml/data/w3_pd.wav new file mode 100644 index 0000000..a92985f Binary files /dev/null and b/ml/data/w3_pd.wav differ diff --git a/ml/data/w3_tpd.wav b/ml/data/w3_tpd.wav new file mode 100644 index 0000000..e5e4a61 Binary files /dev/null and b/ml/data/w3_tpd.wav differ diff --git a/ml/data/w4_pd.wav b/ml/data/w4_pd.wav new file mode 100644 index 0000000..430185c Binary files /dev/null and b/ml/data/w4_pd.wav differ diff --git a/ml/data/w4_tpd.wav b/ml/data/w4_tpd.wav new file mode 100644 index 0000000..0b212bd Binary files /dev/null and b/ml/data/w4_tpd.wav differ diff --git a/ml/data/w5_pd.wav b/ml/data/w5_pd.wav new file mode 100644 index 0000000..fa03aff Binary files /dev/null and b/ml/data/w5_pd.wav differ diff --git a/ml/data/w5_tpd.wav b/ml/data/w5_tpd.wav new file mode 100644 index 0000000..62a6536 Binary files /dev/null and b/ml/data/w5_tpd.wav differ diff --git a/ml/data/w6_pd.wav b/ml/data/w6_pd.wav new file mode 100644 index 0000000..8871906 Binary files /dev/null and b/ml/data/w6_pd.wav differ diff --git a/ml/data/w6_tpd.wav b/ml/data/w6_tpd.wav new file mode 100644 index 0000000..8fb2e06 Binary files /dev/null and b/ml/data/w6_tpd.wav differ diff --git a/ml/data/w7_pd.wav b/ml/data/w7_pd.wav new file mode 100644 index 0000000..a62e4ff Binary files /dev/null and b/ml/data/w7_pd.wav differ diff --git a/ml/data/w7_tpd.wav b/ml/data/w7_tpd.wav new file mode 100644 index 0000000..dabc79b Binary files /dev/null and b/ml/data/w7_tpd.wav differ diff --git a/ml/data/w8_pd.wav b/ml/data/w8_pd.wav new file mode 100644 index 0000000..84a25c9 Binary files /dev/null and b/ml/data/w8_pd.wav differ diff --git a/ml/data/w8_tpd.wav b/ml/data/w8_tpd.wav new file mode 100644 index 0000000..89c6f7e Binary files /dev/null and b/ml/data/w8_tpd.wav differ diff --git a/ml/data/w_pd.wav b/ml/data/w_pd.wav new file mode 100644 index 0000000..eaa0a44 Binary files /dev/null and b/ml/data/w_pd.wav differ diff --git a/ml/data/w_tpd.wav b/ml/data/w_tpd.wav new file mode 100644 index 0000000..cbcdc48 Binary files /dev/null and b/ml/data/w_tpd.wav differ diff --git a/ml/data/x_pd.wav b/ml/data/x_pd.wav new file mode 100644 index 0000000..f4f2dbf Binary files /dev/null and b/ml/data/x_pd.wav differ diff --git a/ml/data/x_tpd.wav b/ml/data/x_tpd.wav new file mode 100644 index 0000000..184f94f Binary files /dev/null and b/ml/data/x_tpd.wav differ diff --git a/ml/database.py b/ml/database.py new file mode 100644 index 0000000..0e161c7 --- /dev/null +++ b/ml/database.py @@ -0,0 +1,681 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +import sqlite3 +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +WEB_DATA_DIR = PROJECT_DIR / "cv_web" / "data" +DB_PATH = WEB_DATA_DIR / "confivoice.db" + +DB_DRIVER = os.getenv("CONFIVOICE_DB_DRIVER", "mysql").lower() +MYSQL_HOST = os.getenv("CONFIVOICE_MYSQL_HOST", "127.0.0.1") +MYSQL_PORT = int(os.getenv("CONFIVOICE_MYSQL_PORT", "3306")) +MYSQL_USER = os.getenv("CONFIVOICE_MYSQL_USER", "root") +MYSQL_PASSWORD = os.getenv("CONFIVOICE_MYSQL_PASSWORD", "") +MYSQL_DATABASE = os.getenv("CONFIVOICE_MYSQL_DATABASE", "confivoice") +PASSWORD_HASH_ITERATIONS = 120_000 + + +def hash_password(password): + salt = secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt.encode("utf-8"), + PASSWORD_HASH_ITERATIONS, + ).hex() + return f"pbkdf2_sha256${PASSWORD_HASH_ITERATIONS}${salt}${digest}" + + +def verify_password(password, stored_hash): + try: + algorithm, iterations, salt, digest = stored_hash.split("$", 3) + if algorithm != "pbkdf2_sha256": + return False + candidate = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt.encode("utf-8"), + int(iterations), + ).hex() + return hmac.compare_digest(candidate, digest) + except Exception: + return False + + +def get_database_label(): + if DB_DRIVER == "mysql": + return f"mysql://{MYSQL_USER}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}" + return str(DB_PATH) + + +def get_connection(): + if DB_DRIVER == "mysql": + return get_mysql_connection() + + WEB_DATA_DIR.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(DB_PATH) + connection.row_factory = sqlite3.Row + return connection + + +def get_mysql_connection(database=MYSQL_DATABASE): + try: + import pymysql + except ImportError as error: + raise RuntimeError("PyMySQL belum terinstall. Jalankan: pip install PyMySQL") from error + + return pymysql.connect( + host=MYSQL_HOST, + port=MYSQL_PORT, + user=MYSQL_USER, + password=MYSQL_PASSWORD, + database=database, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + autocommit=False, + ) + + +def init_mysql_db(): + with get_mysql_connection(database=None) as connection: + with connection.cursor() as cursor: + cursor.execute( + f"CREATE DATABASE IF NOT EXISTS `{MYSQL_DATABASE}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + connection.commit() + + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS prediction_results ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, + student_name VARCHAR(255) NOT NULL, + student_gender VARCHAR(20), + predicted_label VARCHAR(20), + description VARCHAR(255), + confidence DOUBLE NOT NULL DEFAULT 0, + probability_pd DOUBLE NOT NULL DEFAULT 0, + probability_tpd DOUBLE NOT NULL DEFAULT 0, + is_valid_audio TINYINT(1) NOT NULL DEFAULT 1, + error_message TEXT, + audio_duration DOUBLE NOT NULL DEFAULT 0, + volume_score DOUBLE NOT NULL DEFAULT 0, + intonation_score DOUBLE NOT NULL DEFAULT 0, + pause_score DOUBLE NOT NULL DEFAULT 0, + speech_activity_ratio DOUBLE NOT NULL DEFAULT 0, + silence_ratio DOUBLE NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + full_name VARCHAR(255) NOT NULL, + username VARCHAR(120) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + cursor.execute( + """ + SELECT COUNT(*) AS total + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME = 'prediction_results' + AND COLUMN_NAME = 'user_id' + """, + (MYSQL_DATABASE,), + ) + if cursor.fetchone()["total"] == 0: + cursor.execute("ALTER TABLE prediction_results ADD COLUMN user_id INT NULL AFTER id") + cursor.execute( + """ + SELECT COUNT(*) AS total + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME = 'prediction_results' + AND COLUMN_NAME = 'student_gender' + """, + (MYSQL_DATABASE,), + ) + if cursor.fetchone()["total"] == 0: + cursor.execute( + "ALTER TABLE prediction_results " + "ADD COLUMN student_gender VARCHAR(20) NULL AFTER student_name" + ) + connection.commit() + + +def init_db(): + if DB_DRIVER == "mysql": + init_mysql_db() + return + + with get_connection() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS prediction_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + student_name TEXT NOT NULL, + student_gender TEXT, + predicted_label TEXT, + description TEXT, + confidence REAL NOT NULL DEFAULT 0, + probability_pd REAL NOT NULL DEFAULT 0, + probability_tpd REAL NOT NULL DEFAULT 0, + is_valid_audio INTEGER NOT NULL DEFAULT 1, + error_message TEXT, + audio_duration REAL NOT NULL DEFAULT 0, + volume_score REAL NOT NULL DEFAULT 0, + intonation_score REAL NOT NULL DEFAULT 0, + pause_score REAL NOT NULL DEFAULT 0, + speech_activity_ratio REAL NOT NULL DEFAULT 0, + silence_ratio REAL NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + full_name TEXT NOT NULL, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(prediction_results)").fetchall() + } + if "user_id" not in columns: + connection.execute("ALTER TABLE prediction_results ADD COLUMN user_id INTEGER") + if "student_gender" not in columns: + connection.execute( + "ALTER TABLE prediction_results ADD COLUMN student_gender TEXT" + ) + connection.commit() + + +def _clean_username(username): + return username.strip().lower() + + +def _clean_full_name(full_name): + return " ".join(full_name.strip().split()) + + +def _user_row_to_dict(row): + if not row: + return None + return { + "id": row["id"], + "full_name": row["full_name"], + "username": row["username"], + "created_at": str(row["created_at"]), + } + + +def create_user(full_name, username, password): + init_db() + clean_full_name = _clean_full_name(full_name) + clean_username = _clean_username(username) + password_hash = hash_password(password) + + if DB_DRIVER == "mysql": + with get_connection() as connection: + try: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO users (full_name, username, password_hash) + VALUES (%s, %s, %s) + """, + (clean_full_name, clean_username, password_hash), + ) + user_id = cursor.lastrowid + connection.commit() + except Exception: + connection.rollback() + raise + return get_user_by_id(user_id) + + with get_connection() as connection: + cursor = connection.execute( + """ + INSERT INTO users (full_name, username, password_hash) + VALUES (?, ?, ?) + """, + (clean_full_name, clean_username, password_hash), + ) + connection.commit() + return get_user_by_id(cursor.lastrowid) + + +def get_user_by_username(username): + init_db() + clean_username = _clean_username(username) + + if DB_DRIVER == "mysql": + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, full_name, username, password_hash, created_at + FROM users + WHERE username = %s + """, + (clean_username,), + ) + return cursor.fetchone() + + with get_connection() as connection: + row = connection.execute( + """ + SELECT id, full_name, username, password_hash, created_at + FROM users + WHERE username = ? + """, + (clean_username,), + ).fetchone() + return dict(row) if row else None + + +def get_user_by_id(user_id): + init_db() + + if DB_DRIVER == "mysql": + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, full_name, username, created_at + FROM users + WHERE id = %s + """, + (user_id,), + ) + return _user_row_to_dict(cursor.fetchone()) + + with get_connection() as connection: + row = connection.execute( + """ + SELECT id, full_name, username, created_at + FROM users + WHERE id = ? + """, + (user_id,), + ).fetchone() + return _user_row_to_dict(dict(row) if row else None) + + +def list_users(limit=500): + init_db() + safe_limit = max(1, min(int(limit or 500), 1000)) + + if DB_DRIVER == "mysql": + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT id, full_name, username, created_at + FROM users + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + (safe_limit,), + ) + rows = cursor.fetchall() + return [_user_row_to_dict(row) for row in rows] + + with get_connection() as connection: + rows = connection.execute( + """ + SELECT id, full_name, username, created_at + FROM users + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + (safe_limit,), + ).fetchall() + return [_user_row_to_dict(dict(row)) for row in rows] + + +def update_user(user_id, full_name, username, password=None): + init_db() + clean_full_name = _clean_full_name(full_name) + clean_username = _clean_username(username) + clean_password = (password or "").strip() + + if DB_DRIVER == "mysql": + with get_connection() as connection: + try: + with connection.cursor() as cursor: + if clean_password: + cursor.execute( + """ + UPDATE users + SET full_name = %s, username = %s, password_hash = %s + WHERE id = %s + """, + ( + clean_full_name, + clean_username, + hash_password(clean_password), + user_id, + ), + ) + else: + cursor.execute( + """ + UPDATE users + SET full_name = %s, username = %s + WHERE id = %s + """, + (clean_full_name, clean_username, user_id), + ) + affected = cursor.rowcount + connection.commit() + except Exception: + connection.rollback() + raise + return affected > 0 + + with get_connection() as connection: + if clean_password: + cursor = connection.execute( + """ + UPDATE users + SET full_name = ?, username = ?, password_hash = ? + WHERE id = ? + """, + (clean_full_name, clean_username, hash_password(clean_password), user_id), + ) + else: + cursor = connection.execute( + """ + UPDATE users + SET full_name = ?, username = ? + WHERE id = ? + """, + (clean_full_name, clean_username, user_id), + ) + connection.commit() + return cursor.rowcount > 0 + + +def delete_user(user_id): + init_db() + + if DB_DRIVER == "mysql": + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM users WHERE id = %s", (user_id,)) + affected = cursor.rowcount + connection.commit() + return affected > 0 + + with get_connection() as connection: + cursor = connection.execute("DELETE FROM users WHERE id = ?", (user_id,)) + connection.commit() + return cursor.rowcount > 0 + + +def authenticate_user(username, password): + row = get_user_by_username(username) + if not row or not verify_password(password, row["password_hash"]): + return None + return _user_row_to_dict(row) + + +def save_prediction_result(student_name, result): + init_db() + audio_quality = result.get("audio_quality") or {} + indicators = result.get("voice_indicators") or {} + + values = ( + result.get("user_id"), + student_name, + result.get("student_gender"), + result.get("predicted_label") or result.get("label"), + result.get("description"), + float(result.get("confidence") or 0), + float(result.get("probability_pd") or 0), + float(result.get("probability_tpd") or 0), + 1 if result.get("is_valid_audio") is not False else 0, + result.get("error_message"), + float(audio_quality.get("duration") or 0), + float(indicators.get("volume_score") or 0), + float(indicators.get("intonation_score") or 0), + float(indicators.get("pause_score") or 0), + float(indicators.get("speech_activity_ratio") or 0), + float(indicators.get("silence_ratio") or 0), + ) + + if DB_DRIVER == "mysql": + return save_prediction_result_mysql(values) + + with get_connection() as connection: + cursor = connection.execute( + """ + INSERT INTO prediction_results ( + user_id, student_name, student_gender, predicted_label, description, confidence, + probability_pd, probability_tpd, is_valid_audio, error_message, + audio_duration, volume_score, intonation_score, pause_score, + speech_activity_ratio, silence_ratio + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + values, + ) + connection.commit() + return cursor.lastrowid + + +def save_prediction_result_mysql(values): + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO prediction_results ( + user_id, student_name, student_gender, predicted_label, description, confidence, + probability_pd, probability_tpd, is_valid_audio, error_message, + audio_duration, volume_score, intonation_score, pause_score, + speech_activity_ratio, silence_ratio + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + values, + ) + prediction_id = cursor.lastrowid + connection.commit() + return prediction_id + + +def get_prediction_result(prediction_id): + init_db() + if DB_DRIVER == "mysql": + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT + id, user_id, student_name, student_gender, predicted_label, description, confidence, + probability_pd, probability_tpd, is_valid_audio, error_message, + audio_duration, volume_score, intonation_score, pause_score, + speech_activity_ratio, silence_ratio, created_at + FROM prediction_results + WHERE id = %s + """, + (prediction_id,), + ) + row = cursor.fetchone() + if row: + row["created_at"] = str(row["created_at"]) + return row + + with get_connection() as connection: + row = connection.execute( + """ + SELECT + id, user_id, student_name, student_gender, predicted_label, description, confidence, + probability_pd, probability_tpd, is_valid_audio, error_message, + audio_duration, volume_score, intonation_score, pause_score, + speech_activity_ratio, silence_ratio, created_at + FROM prediction_results + WHERE id = ? + """, + (prediction_id,), + ).fetchone() + + return dict(row) if row else None + + +def update_prediction_result(prediction_id, values): + init_db() + if DB_DRIVER == "mysql": + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + UPDATE prediction_results + SET + student_name = %s, + student_gender = %s, + predicted_label = %s, + description = %s, + confidence = %s, + probability_pd = %s, + probability_tpd = %s, + is_valid_audio = %s, + error_message = %s, + audio_duration = %s, + volume_score = %s, + intonation_score = %s, + pause_score = %s, + speech_activity_ratio = %s, + silence_ratio = %s + WHERE id = %s + """, + (*values, prediction_id), + ) + affected = cursor.rowcount + connection.commit() + return affected > 0 + + with get_connection() as connection: + cursor = connection.execute( + """ + UPDATE prediction_results + SET + student_name = ?, + student_gender = ?, + predicted_label = ?, + description = ?, + confidence = ?, + probability_pd = ?, + probability_tpd = ?, + is_valid_audio = ?, + error_message = ?, + audio_duration = ?, + volume_score = ?, + intonation_score = ?, + pause_score = ?, + speech_activity_ratio = ?, + silence_ratio = ? + WHERE id = ? + """, + (*values, prediction_id), + ) + connection.commit() + return cursor.rowcount > 0 + + +def delete_prediction_result(prediction_id): + init_db() + if DB_DRIVER == "mysql": + with get_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "DELETE FROM prediction_results WHERE id = %s", + (prediction_id,), + ) + affected = cursor.rowcount + connection.commit() + return affected > 0 + + with get_connection() as connection: + cursor = connection.execute( + "DELETE FROM prediction_results WHERE id = ?", + (prediction_id,), + ) + connection.commit() + return cursor.rowcount > 0 + + +def list_prediction_results(limit=200, user_id=None): + init_db() + if DB_DRIVER == "mysql": + return list_prediction_results_mysql(limit=limit, user_id=user_id) + + with get_connection() as connection: + where_clause = "WHERE user_id = ?" if user_id is not None else "" + params = (user_id, limit) if user_id is not None else (limit,) + rows = connection.execute( + f""" + SELECT + id, user_id, student_name, student_gender, predicted_label, description, confidence, + probability_pd, probability_tpd, is_valid_audio, error_message, + audio_duration, volume_score, intonation_score, pause_score, + speech_activity_ratio, silence_ratio, created_at + FROM prediction_results + {where_clause} + ORDER BY created_at DESC, id DESC + LIMIT ? + """, + params, + ).fetchall() + + return [dict(row) for row in rows] + + +def list_prediction_results_mysql(limit=200, user_id=None): + with get_connection() as connection: + with connection.cursor() as cursor: + where_clause = "WHERE user_id = %s" if user_id is not None else "" + params = (user_id, limit) if user_id is not None else (limit,) + cursor.execute( + f""" + SELECT + id, user_id, student_name, student_gender, predicted_label, description, confidence, + probability_pd, probability_tpd, is_valid_audio, error_message, + audio_duration, volume_score, intonation_score, pause_score, + speech_activity_ratio, silence_ratio, created_at + FROM prediction_results + {where_clause} + ORDER BY created_at DESC, id DESC + LIMIT %s + """, + params, + ) + rows = cursor.fetchall() + + for row in rows: + row["created_at"] = str(row["created_at"]) + return rows diff --git a/ml/evaluasi_model.txt b/ml/evaluasi_model.txt new file mode 100644 index 0000000..a8505bb --- /dev/null +++ b/ml/evaluasi_model.txt @@ -0,0 +1,118 @@ +Mengecek kualitas dataset... + +=== Cek Kualitas Dataset === +a_pd.wav | label=PD | durasi=3.60s | rms=0.00308 +a_tpd.wav | label=TPD | durasi=3.64s | rms=0.00928 +b_pd.wav | label=PD | durasi=4.00s | rms=0.00563 +b_tpd.wav | label=TPD | durasi=3.74s | rms=0.01337 +c_pd.wav | label=PD | durasi=3.74s | rms=0.00478 +c_tpd.wav | label=TPD | durasi=3.84s | rms=0.01149 +d_pd.wav | label=PD | durasi=3.74s | rms=0.00468 +d_tpd.wav | label=TPD | durasi=3.24s | rms=0.01736 +e_pd.wav | label=PD | durasi=4.10s | rms=0.00116 +e_tpd.wav | label=TPD | durasi=3.37s | rms=0.00500 +f_pd.wav | label=PD | durasi=4.10s | rms=0.00141 +f_tpd.wav | label=TPD | durasi=3.44s | rms=0.00376 +g_pd.wav | label=PD | durasi=3.74s | rms=0.00255 +g_tpd.wav | label=TPD | durasi=3.50s | rms=0.00724 +h_pd.wav | label=PD | durasi=3.40s | rms=0.00259 +h_tpd.wav | label=TPD | durasi=3.67s | rms=0.01442 +i_pd.wav | label=PD | durasi=3.34s | rms=0.00189 +i_tpd.wav | label=TPD | durasi=3.17s | rms=0.00118 +j_pd.wav | label=PD | durasi=3.60s | rms=0.01220 +j_tpd.wav | label=TPD | durasi=3.30s | rms=0.00891 +k_pd.wav | label=PD | durasi=3.34s | rms=0.00202 +k_tpd.wav | label=TPD | durasi=3.50s | rms=0.00756 +l_pd.wav | label=PD | durasi=3.77s | rms=0.00381 +l_tpd.wav | label=TPD | durasi=3.50s | rms=0.01517 +m_pd.wav | label=PD | durasi=3.17s | rms=0.00310 +m_tpd.wav | label=TPD | durasi=3.17s | rms=0.00371 +n_pd.wav | label=PD | durasi=3.57s | rms=0.00158 +n_tpd.wav | label=TPD | durasi=3.57s | rms=0.00298 +o_pd.wav | label=PD | durasi=3.57s | rms=0.00497 +o_tpd.wav | label=TPD | durasi=3.30s | rms=0.01104 +p_pd.wav | label=PD | durasi=3.87s | rms=0.00531 +p_tpd.wav | label=TPD | durasi=3.50s | rms=0.01038 +q_pd.wav | label=PD | durasi=3.54s | rms=0.00760 +q_tpd.wav | label=TPD | durasi=3.50s | rms=0.02280 +r_pd.wav | label=PD | durasi=3.40s | rms=0.00380 +r_tpd.wav | label=TPD | durasi=3.40s | rms=0.01185 +s_pd.wav | label=PD | durasi=3.67s | rms=0.00202 +s_tpd.wav | label=TPD | durasi=3.50s | rms=0.00555 +t_pd.wav | label=PD | durasi=3.57s | rms=0.00125 +t_tpd.wav | label=TPD | durasi=4.37s | rms=0.00370 +u_pd.wav | label=PD | durasi=4.00s | rms=0.00748 +u_tpd.wav | label=TPD | durasi=3.77s | rms=0.01262 +v_pd.wav | label=PD | durasi=3.57s | rms=0.00287 +v_tpd.wav | label=TPD | durasi=3.60s | rms=0.01220 +w_pd.wav | label=PD | durasi=4.07s | rms=0.00437 +w_tpd.wav | label=TPD | durasi=3.24s | rms=0.00558 +x_pd.wav | label=PD | durasi=3.50s | rms=0.00239 +x_tpd.wav | label=TPD | durasi=4.07s | rms=0.01880 + +Jumlah data: +PD : 24 +TPD: 24 + +Rekomendasi rekam ulang/perbaikan: +Tidak ada masalah kualitas audio yang jelas. + +Membaca dataset dan mengekstraksi fitur... + +=== Distribusi Label Dataset === +PD : 24 +TPD: 24 + +Total data valid: 48 +Jumlah fitur per audio: 78 +Distribusi label: {'PD': 24, 'TPD': 24} + +Melakukan GridSearchCV SVM dengan scoring f1_macro... +Fitting 5 folds for each of 16 candidates, totalling 80 fits + +=== Hasil GridSearchCV === +Best params: {'svm__C': 1, 'svm__gamma': 'scale', 'svm__kernel': 'rbf'} +Best CV f1_macro: 0.7873 +Urutan kelas model: ['PD', 'TPD'] + +=== Evaluasi Cross-Validation === +Accuracy : 0.7917 +Balanced Accuracy : 0.7917 +Precision Macro : 0.7937 +Recall Macro : 0.7917 +F1 Macro : 0.7913 + +=== Classification Report === + precision recall f1-score support + + PD - Percaya Diri 0.77 0.83 0.80 24 +TPD - Tidak Percaya Diri 0.82 0.75 0.78 24 + + accuracy 0.79 48 + macro avg 0.79 0.79 0.79 48 + weighted avg 0.79 0.79 0.79 48 + +=== Confusion Matrix === +Urutan label: ['PD', 'TPD'] +[[20 4] + [ 6 18]] + +=== Ringkasan Benar/Salah per Kelas === +PD: benar=20, salah=4, total=24 +TPD: benar=18, salah=6, total=24 + +=== File yang Salah Prediksi === +f_tpd.wav | TPD | PD +i_tpd.wav | TPD | PD +j_pd.wav | PD | TPD +k_tpd.wav | TPD | PD +m_pd.wav | PD | TPD +m_tpd.wav | TPD | PD +p_pd.wav | PD | TPD +q_pd.wav | PD | TPD +s_tpd.wav | TPD | PD +w_tpd.wav | TPD | PD + +Melatih ulang model terbaik dengan seluruh dataset... +Urutan kelas model final: ['PD', 'TPD'] +Model terbaik berhasil disimpan ke: /Users/user/TA/confivoice3/ml/models/svm_voice_confidence_model.joblib diff --git a/ml/features.py b/ml/features.py new file mode 100644 index 0000000..0c0bac8 --- /dev/null +++ b/ml/features.py @@ -0,0 +1,549 @@ +from collections import Counter +from pathlib import Path +import warnings + +import librosa +import numpy as np + + +SAMPLE_RATE = 22050 +LABEL_PD = "PD" +LABEL_TPD = "TPD" +VALID_LABELS = {LABEL_PD, LABEL_TPD} +N_MFCC = 13 +MIN_DURATION_SECONDS = 2.00 +MIN_RMS_FOR_USABLE_AUDIO = 0.001 +QUIET_RMS_THRESHOLD = 0.003 +CLIPPING_AMPLITUDE_THRESHOLD = 0.99 +CLIPPING_RATIO_THRESHOLD = 0.01 +FRAME_LENGTH = 2048 +HOP_LENGTH = 512 +FEATURE_NAMES = ( + [f"mfcc_{index}_mean" for index in range(1, N_MFCC + 1)] + + [f"mfcc_{index}_std" for index in range(1, N_MFCC + 1)] + + [f"delta_mfcc_{index}_mean" for index in range(1, N_MFCC + 1)] + + [f"delta_mfcc_{index}_std" for index in range(1, N_MFCC + 1)] + + ["rms_normalized_mean", "rms_normalized_std"] + + ["rms_mean", "rms_std", "peak_amplitude", "clipping_ratio", "energy_stability"] + + ["pitch_mean", "pitch_std", "pitch_range", "pitch_stability", "pitch_variation"] + + ["zcr_mean", "zcr_std"] + + ["spectral_centroid_mean", "spectral_centroid_std"] + + ["spectral_bandwidth_mean", "spectral_bandwidth_std"] + + ["spectral_rolloff_mean", "spectral_rolloff_std"] + + [ + "active_duration", + "silence_duration", + "silence_ratio", + "number_of_pauses", + "average_pause_duration", + "speech_activity_ratio", + ] +) + + +def get_label_from_filename(file_path): + """ + Mengambil label dari nama file atau nama folder. + + Urutan pengecekan penting: + - cek "_tpd" lebih dulu + - baru cek "_pd" + """ + path = Path(file_path) + filename = path.stem.lower() + parent = path.parent.name.lower() + + if "_tpd" in filename or parent in {"tpd", "not_confident", "tidak_percaya_diri"}: + return LABEL_TPD + if "_pd" in filename or parent in {"pd", "confident", "percaya_diri"}: + return LABEL_PD + + return None + + +def validate_label(label, file_path): + if label not in VALID_LABELS: + raise ValueError(f"Label tidak valid pada {Path(file_path).name}: {label}") + + +def load_audio_raw(file_path, sample_rate=SAMPLE_RATE): + y, sr = librosa.load(file_path, sr=sample_rate, mono=True) + + if y.size == 0: + raise ValueError(f"Audio kosong: {file_path}") + + return y.astype(np.float32), sr + + +def calculate_audio_quality(y, sr): + duration = float(librosa.get_duration(y=y, sr=sr)) if y.size else 0.0 + rms_frames = librosa.feature.rms(y=y, frame_length=FRAME_LENGTH, hop_length=HOP_LENGTH)[0] if y.size else np.array([0.0]) + rms_mean = float(np.mean(rms_frames)) + rms_std = float(np.std(rms_frames)) + peak_amplitude = float(np.max(np.abs(y))) if y.size else 0.0 + clipping_ratio = float(np.mean(np.abs(y) >= CLIPPING_AMPLITUDE_THRESHOLD)) if y.size else 0.0 + + return { + "duration": duration, + "rms_mean": rms_mean, + "rms_std": rms_std, + "peak_amplitude": peak_amplitude, + "clipping_ratio": clipping_ratio, + "is_clipped": bool(clipping_ratio > CLIPPING_RATIO_THRESHOLD or peak_amplitude >= CLIPPING_AMPLITUDE_THRESHOLD), + "is_too_quiet": bool(rms_mean < QUIET_RMS_THRESHOLD), + "is_too_short": bool(duration < MIN_DURATION_SECONDS), + } + + +def validate_audio_for_prediction(audio_quality): + if audio_quality["is_too_short"]: + raise ValueError("Audio terlalu pendek, silakan rekam ulang.") + if audio_quality["is_clipped"]: + raise ValueError("Audio terlalu keras/pecah, silakan rekam ulang dengan volume normal.") + + +def normalize_audio(y, target_peak=0.95): + peak_amplitude = np.max(np.abs(y)) if y.size else 0.0 + if peak_amplitude <= 0: + return y.astype(np.float32) + return (y / peak_amplitude * target_peak).astype(np.float32) + + +def load_and_preprocess_audio(file_path, sample_rate=SAMPLE_RATE, validate_quality=False): + """ + Membaca audio dengan preprocessing konsisten untuk training dan prediksi: + mono, sample rate 22050 Hz, trim silence, dan normalisasi volume. + """ + y, sr = load_audio_raw(file_path, sample_rate=sample_rate) + audio_quality = calculate_audio_quality(y, sr) + + if validate_quality: + validate_audio_for_prediction(audio_quality) + + y, _ = librosa.effects.trim(y, top_db=30) + + if y.size == 0: + raise ValueError(f"Audio hanya berisi silence: {file_path}") + + duration = librosa.get_duration(y=y, sr=sr) + if validate_quality and duration < MIN_DURATION_SECONDS: + raise ValueError( + f"Audio terlalu pendek: {duration:.2f} detik. Minimal {MIN_DURATION_SECONDS:.2f} detik." + ) + + rms_value = float(np.sqrt(np.mean(y**2))) + if rms_value < MIN_RMS_FOR_USABLE_AUDIO: + raise ValueError( + f"Audio terlalu pelan/silent. RMS={rms_value:.5f}, " + f"minimal {MIN_RMS_FOR_USABLE_AUDIO:.5f}." + ) + + y = normalize_audio(y) + + return y.astype(np.float32), sr + + +def mean_std(feature_matrix): + """ + Mengubah fitur frame-based menjadi statistik tetap. + Output selalu 1 dimensi dan stabil untuk SVM. + """ + feature_matrix = np.atleast_2d(feature_matrix) + return np.concatenate( + [ + np.mean(feature_matrix, axis=1), + np.std(feature_matrix, axis=1), + ] + ) + + +def extract_pitch_features(y, sr): + """ + Mengambil ringkasan fundamental frequency (pitch) dengan pyin. + Jika pitch tidak terdeteksi, nilai pitch dibuat 0 agar fitur tetap konsisten. + """ + f0, _, _ = librosa.pyin( + y, + fmin=librosa.note_to_hz("C2"), + fmax=librosa.note_to_hz("C7"), + sr=sr, + ) + voiced_f0 = f0[~np.isnan(f0)] + + if voiced_f0.size == 0: + return { + "pitch_mean": 0.0, + "pitch_std": 0.0, + "pitch_range": 0.0, + "pitch_stability": 0.0, + "pitch_variation": 0.0, + } + + pitch_mean = float(np.mean(voiced_f0)) + pitch_std = float(np.std(voiced_f0)) + pitch_range = float(np.max(voiced_f0) - np.min(voiced_f0)) + pitch_variation = float(pitch_std / (pitch_mean + 1e-8)) + pitch_stability = float(1.0 / (1.0 + pitch_variation)) + + return { + "pitch_mean": pitch_mean, + "pitch_std": pitch_std, + "pitch_range": pitch_range, + "pitch_stability": pitch_stability, + "pitch_variation": pitch_variation, + } + + +def extract_pause_features(y, sr): + y_trimmed, _ = librosa.effects.trim(y, top_db=30) + if y_trimmed.size == 0: + y_trimmed = y + + total_duration = float(librosa.get_duration(y=y_trimmed, sr=sr)) + rms_frames = librosa.feature.rms(y=y_trimmed, frame_length=FRAME_LENGTH, hop_length=HOP_LENGTH)[0] + + if rms_frames.size == 0 or total_duration <= 0: + return { + "active_duration": 0.0, + "silence_duration": total_duration, + "silence_ratio": 1.0, + "number_of_pauses": 0.0, + "average_pause_duration": 0.0, + "speech_activity_ratio": 0.0, + } + + max_rms = float(np.max(rms_frames)) + median_rms = float(np.median(rms_frames)) + adaptive_threshold = max(0.0006, max_rms * 0.06, median_rms * 0.55) + active_frames = rms_frames > adaptive_threshold + frame_duration = HOP_LENGTH / sr + + # Lubang diam yang sangat pendek masih dianggap bagian dari artikulasi normal. + min_gap_frames = int(np.ceil(0.18 / frame_duration)) + smoothed_active_frames = active_frames.copy() + inactive_run = 0 + inactive_start = 0 + for index, is_active in enumerate(active_frames): + if not is_active: + if inactive_run == 0: + inactive_start = index + inactive_run += 1 + else: + if 0 < inactive_run < min_gap_frames: + smoothed_active_frames[inactive_start:index] = True + inactive_run = 0 + + if 0 < inactive_run < min_gap_frames: + smoothed_active_frames[inactive_start:] = True + + active_duration = float(min(total_duration, np.sum(smoothed_active_frames) * frame_duration)) + silence_duration = float(max(0.0, total_duration - active_duration)) + silence_ratio = float(silence_duration / (total_duration + 1e-8)) + speech_activity_ratio = float(active_duration / (total_duration + 1e-8)) + + pauses = [] + inactive_run = 0 + for is_active in smoothed_active_frames: + if is_active: + if inactive_run * frame_duration >= 0.30: + pauses.append(inactive_run * frame_duration) + inactive_run = 0 + else: + inactive_run += 1 + + if inactive_run * frame_duration >= 0.30: + pauses.append(inactive_run * frame_duration) + + return { + "active_duration": active_duration, + "silence_duration": silence_duration, + "silence_ratio": silence_ratio, + "number_of_pauses": float(len(pauses)), + "average_pause_duration": float(np.mean(pauses)) if pauses else 0.0, + "speech_activity_ratio": speech_activity_ratio, + } + + +def calculate_voice_indicator_scores(raw_quality, pitch_features, pause_features): + rms_mean = raw_quality["rms_mean"] + rms_std = raw_quality["rms_std"] + clipping_ratio = raw_quality["clipping_ratio"] + peak_amplitude = raw_quality["peak_amplitude"] + + volume_strength = np.clip( + np.log10((rms_mean + 1e-8) / QUIET_RMS_THRESHOLD + 1.0) / np.log10(14.0), + 0.0, + 1.0, + ) + energy_stability = 1.0 / (1.0 + (rms_std / (rms_mean + 1e-8))) + clipping_penalty = np.clip((clipping_ratio / CLIPPING_RATIO_THRESHOLD) + max(0.0, peak_amplitude - 0.95) * 10.0, 0.0, 1.0) + volume_score = float(np.clip((0.65 * volume_strength + 0.35 * energy_stability) * (1.0 - clipping_penalty), 0.0, 1.0)) + + pitch_variation = pitch_features["pitch_variation"] + pitch_range = pitch_features["pitch_range"] + natural_variation = np.clip(pitch_range / 180.0, 0.0, 1.0) + not_flat = np.clip(pitch_variation / 0.08, 0.0, 1.0) + not_shaky = 1.0 - np.clip(max(0.0, pitch_variation - 0.35) / 0.45, 0.0, 1.0) + intonation_score = float(np.clip(0.35 * natural_variation + 0.30 * not_flat + 0.35 * not_shaky, 0.0, 1.0)) + + silence_ratio = pause_features["silence_ratio"] + average_pause_duration = pause_features["average_pause_duration"] + number_of_pauses = pause_features["number_of_pauses"] + silence_penalty = np.clip((silence_ratio - 0.12) / 0.38, 0.0, 1.0) + pause_count_penalty = np.clip(number_of_pauses / 5.0, 0.0, 1.0) + pause_duration_penalty = np.clip(average_pause_duration / 0.90, 0.0, 1.0) + pause_score = 1.0 - (0.55 * silence_penalty + 0.25 * pause_count_penalty + 0.20 * pause_duration_penalty) + + return { + "volume_score": float(np.clip(volume_score, 0.0, 1.0)), + "intonation_score": float(np.clip(intonation_score, 0.0, 1.0)), + "pause_score": float(np.clip(pause_score, 0.0, 1.0)), + "speech_activity_ratio": float(np.clip(pause_features["speech_activity_ratio"], 0.0, 1.0)), + "silence_ratio": float(np.clip(silence_ratio, 0.0, 1.0)), + } + + +def build_prediction_explanation(predicted_label, confidence, indicators): + weak_points = [] + strong_points = [] + + if indicators["volume_score"] >= 0.65: + strong_points.append("volume stabil") + else: + weak_points.append("volume kurang stabil atau kurang jelas") + + if indicators["intonation_score"] >= 0.65: + strong_points.append("intonasi cukup bervariasi") + else: + weak_points.append("intonasi kurang stabil") + + if indicators["pause_score"] >= 0.65: + strong_points.append("jeda bicara sedikit dan wajar") + else: + weak_points.append("jeda bicara cukup banyak") + + if confidence < 0.60: + reason = " dan ".join(weak_points[:2]) if weak_points else "indikator suara saling berdekatan" + return f"Model belum cukup yakin karena {reason}." + + if predicted_label == LABEL_PD: + reason = ", ".join(strong_points[:2]) + if len(strong_points) > 2: + reason += f", dan {strong_points[2]}" + return f"Suara terdeteksi percaya diri karena {reason}." + + reason = " dan ".join(weak_points[:2]) if weak_points else "kombinasi indikator belum menunjukkan kestabilan yang cukup" + return f"Suara terdeteksi tidak percaya diri karena {reason}." + + +def analyze_audio(file_path, sample_rate=SAMPLE_RATE, validate_quality=False): + raw_y, sr = load_audio_raw(file_path, sample_rate=sample_rate) + raw_quality = calculate_audio_quality(raw_y, sr) + + if validate_quality: + validate_audio_for_prediction(raw_quality) + + y, sr = load_and_preprocess_audio(file_path, sample_rate=sample_rate, validate_quality=False) + + mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=N_MFCC, hop_length=HOP_LENGTH) + delta_mfcc = librosa.feature.delta(mfcc) + rms_normalized = librosa.feature.rms(y=y, frame_length=FRAME_LENGTH, hop_length=HOP_LENGTH) + rms_raw = librosa.feature.rms(y=raw_y, frame_length=FRAME_LENGTH, hop_length=HOP_LENGTH)[0] + zcr = librosa.feature.zero_crossing_rate(y, frame_length=FRAME_LENGTH, hop_length=HOP_LENGTH) + spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=HOP_LENGTH) + spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr, hop_length=HOP_LENGTH) + spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr, hop_length=HOP_LENGTH) + pitch_features = extract_pitch_features(y, sr) + pause_features = extract_pause_features(raw_y, sr) + + rms_mean = float(np.mean(rms_raw)) + rms_std = float(np.std(rms_raw)) + energy_stability = float(1.0 / (1.0 + (rms_std / (rms_mean + 1e-8)))) + + scalar_features = { + "rms_mean": rms_mean, + "rms_std": rms_std, + "peak_amplitude": raw_quality["peak_amplitude"], + "clipping_ratio": raw_quality["clipping_ratio"], + "energy_stability": energy_stability, + **pitch_features, + **pause_features, + } + + feature_vector = np.concatenate( + [ + mean_std(mfcc), + mean_std(delta_mfcc), + mean_std(rms_normalized), + np.array( + [ + scalar_features["rms_mean"], + scalar_features["rms_std"], + scalar_features["peak_amplitude"], + scalar_features["clipping_ratio"], + scalar_features["energy_stability"], + scalar_features["pitch_mean"], + scalar_features["pitch_std"], + scalar_features["pitch_range"], + scalar_features["pitch_stability"], + scalar_features["pitch_variation"], + ], + dtype=np.float32, + ), + mean_std(zcr), + mean_std(spectral_centroid), + mean_std(spectral_bandwidth), + mean_std(spectral_rolloff), + np.array( + [ + scalar_features["active_duration"], + scalar_features["silence_duration"], + scalar_features["silence_ratio"], + scalar_features["number_of_pauses"], + scalar_features["average_pause_duration"], + scalar_features["speech_activity_ratio"], + ], + dtype=np.float32, + ), + ] + ) + + if feature_vector.ndim != 1: + raise ValueError("Fitur audio harus 1 dimensi.") + if feature_vector.size != len(FEATURE_NAMES): + raise ValueError( + f"Jumlah fitur tidak konsisten: {feature_vector.size}, seharusnya {len(FEATURE_NAMES)}." + ) + if not np.all(np.isfinite(feature_vector)): + raise ValueError("Fitur audio mengandung NaN atau infinity.") + + indicators = calculate_voice_indicator_scores(raw_quality, pitch_features, pause_features) + + return { + "features": feature_vector.astype(np.float32), + "audio_quality": raw_quality, + "voice_indicators": indicators, + "feature_details": scalar_features, + } + + +def extract_features(file_path, sample_rate=SAMPLE_RATE): + """ + Ekstraksi fitur suara yang sama untuk training dan prediksi: + - MFCC mean dan std + - RMS Energy mean dan std + - Zero Crossing Rate mean dan std + - Spectral Centroid mean dan std + - Spectral Bandwidth mean dan std + - Spectral Rolloff mean dan std + - Pitch/fundamental frequency + - Durasi suara aktif + """ + return analyze_audio(file_path, sample_rate=sample_rate)["features"] + + +def load_dataset(data_dir): + """ + Membaca semua file .wav pada folder data. + File tanpa label valid atau file rusak dilewati dengan peringatan. + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + + if not audio_files: + raise FileNotFoundError(f"Tidak ada file .wav di folder: {data_path}") + + features = [] + labels = [] + used_files = [] + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + warnings.warn( + f"File dilewati karena nama/folder tidak mengandung label PD atau TPD: " + f"{audio_file.name}" + ) + continue + + try: + validate_label(label, audio_file) + features.append(extract_features(audio_file)) + labels.append(label) + used_files.append(audio_file) + except Exception as error: + warnings.warn(f"File dilewati karena gagal diproses: {audio_file.name} ({error})") + + if not features: + raise ValueError("Tidak ada file audio valid yang berhasil diproses.") + + label_counts = Counter(labels) + print("\n=== Distribusi Label Dataset ===") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + invalid_labels = set(labels) - VALID_LABELS + if invalid_labels: + raise ValueError(f"Ditemukan label tidak valid: {sorted(invalid_labels)}") + + return np.array(features), np.array(labels), used_files + + +def check_dataset_quality(data_dir): + """ + Mengecek kualitas dataset: + - jumlah data PD dan TPD + - durasi setiap audio + - audio terlalu pendek + - audio terlalu pelan/silent + - file rusak + - rekomendasi file yang perlu direkam ulang + """ + data_path = Path(data_dir) + audio_files = sorted(data_path.rglob("*.wav")) + label_counts = Counter() + problems = [] + + print("\n=== Cek Kualitas Dataset ===") + + for audio_file in audio_files: + label = get_label_from_filename(audio_file) + if label is None: + problems.append((audio_file.name, "Label tidak ditemukan")) + continue + + label_counts[label] += 1 + + try: + y_raw, sr = librosa.load(audio_file, sr=SAMPLE_RATE, mono=True) + duration_raw = librosa.get_duration(y=y_raw, sr=sr) + rms_raw = float(np.sqrt(np.mean(y_raw**2))) if y_raw.size else 0.0 + + issue_notes = [] + if duration_raw < MIN_DURATION_SECONDS: + issue_notes.append(f"terlalu pendek ({duration_raw:.2f} detik)") + if rms_raw < MIN_RMS_FOR_USABLE_AUDIO: + issue_notes.append(f"terlalu pelan/silent (RMS={rms_raw:.5f})") + + print( + f"{audio_file.name} | label={label} | durasi={duration_raw:.2f}s | " + f"rms={rms_raw:.5f}" + ) + + if issue_notes: + problems.append((audio_file.name, ", ".join(issue_notes))) + except Exception as error: + problems.append((audio_file.name, f"file rusak/gagal dibaca ({error})")) + + print("\nJumlah data:") + print(f"PD : {label_counts.get(LABEL_PD, 0)}") + print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") + + print("\nRekomendasi rekam ulang/perbaikan:") + if not problems: + print("Tidak ada masalah kualitas audio yang jelas.") + else: + for filename, reason in problems: + print(f"- {filename}: {reason}") + + return problems diff --git a/ml/migrate_sqlite_to_mysql.py b/ml/migrate_sqlite_to_mysql.py new file mode 100644 index 0000000..a719034 --- /dev/null +++ b/ml/migrate_sqlite_to_mysql.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import sqlite3 + +from database import DB_PATH, init_db, save_prediction_result + + +def migrate(): + if not DB_PATH.exists(): + print(f"SQLite database tidak ditemukan: {DB_PATH}") + return + + init_db() + + with sqlite3.connect(DB_PATH) as sqlite_connection: + sqlite_connection.row_factory = sqlite3.Row + rows = sqlite_connection.execute( + """ + SELECT + student_name, + predicted_label, + description, + confidence, + probability_pd, + probability_tpd, + is_valid_audio, + error_message, + audio_duration, + volume_score, + intonation_score, + pause_score, + speech_activity_ratio, + silence_ratio + FROM prediction_results + ORDER BY id ASC + """ + ).fetchall() + + migrated_count = 0 + for row in rows: + result = { + "predicted_label": row["predicted_label"], + "label": row["predicted_label"], + "description": row["description"], + "confidence": row["confidence"], + "probability_pd": row["probability_pd"], + "probability_tpd": row["probability_tpd"], + "is_valid_audio": bool(row["is_valid_audio"]), + "error_message": row["error_message"], + "audio_quality": { + "duration": row["audio_duration"], + }, + "voice_indicators": { + "volume_score": row["volume_score"], + "intonation_score": row["intonation_score"], + "pause_score": row["pause_score"], + "speech_activity_ratio": row["speech_activity_ratio"], + "silence_ratio": row["silence_ratio"], + }, + } + save_prediction_result(row["student_name"], result) + migrated_count += 1 + + print(f"Selesai migrasi {migrated_count} data dari SQLite ke MySQL.") + + +if __name__ == "__main__": + migrate() diff --git a/ml/models/svm_voice_confidence_model.joblib b/ml/models/svm_voice_confidence_model.joblib new file mode 100644 index 0000000..6e288fd Binary files /dev/null and b/ml/models/svm_voice_confidence_model.joblib differ diff --git a/ml/predict.py b/ml/predict.py new file mode 100644 index 0000000..fb06970 --- /dev/null +++ b/ml/predict.py @@ -0,0 +1,88 @@ +import argparse +import tempfile +from pathlib import Path + +import joblib + +from audio_utils import SUPPORTED_AUDIO_EXTENSIONS, convert_to_wav +from features import LABEL_PD, LABEL_TPD, extract_features + + +BASE_DIR = Path(__file__).resolve().parent +MODEL_PATH = BASE_DIR / "models" / "svm_voice_confidence_model.joblib" +CONFIDENCE_THRESHOLD = 0.60 + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +def prepare_audio_for_prediction(audio_path): + """ + Menyiapkan audio prediksi menjadi WAV mono 22050 Hz. + Path hasil konversi dikembalikan bersama flag apakah file temporer perlu dihapus. + """ + audio_path = Path(audio_path) + extension = audio_path.suffix.lower() + + if extension not in SUPPORTED_AUDIO_EXTENSIONS: + allowed = ", ".join(sorted(SUPPORTED_AUDIO_EXTENSIONS)) + raise ValueError(f"Format audio tidak didukung: {extension}. Format yang didukung: {allowed}") + + temp_wav_path = Path(tempfile.NamedTemporaryFile(delete=False, suffix=".wav").name) + convert_to_wav(audio_path, temp_wav_path) + return temp_wav_path + + +def predict_audio(audio_path, model_path=MODEL_PATH): + """ + Memprediksi satu file audio. + Audio diproses dengan preprocessing dan ekstraksi fitur yang sama seperti training. + """ + model = joblib.load(model_path) + + temp_wav_path = prepare_audio_for_prediction(audio_path) + try: + features = extract_features(temp_wav_path).reshape(1, -1) + predicted_label = model.predict(features)[0] + probabilities = model.predict_proba(features)[0] + class_probabilities = dict(zip(model.classes_, probabilities)) + confidence = class_probabilities[predicted_label] + finally: + temp_wav_path.unlink(missing_ok=True) + + return predicted_label, LABEL_DESCRIPTION[predicted_label], confidence, class_probabilities + + +def main(): + parser = argparse.ArgumentParser(description="Prediksi tingkat percaya diri dari audio") + parser.add_argument("audio_path", help="Path file audio yang ingin diprediksi") + args = parser.parse_args() + + audio_path = Path(args.audio_path) + if not audio_path.exists(): + raise FileNotFoundError(f"File tidak ditemukan: {audio_path}") + + label, description, confidence, probabilities = predict_audio(audio_path) + + probability_pd = probabilities.get(LABEL_PD, 0.0) + probability_tpd = probabilities.get(LABEL_TPD, 0.0) + + print("=== Hasil Prediksi ===") + print(f"File : {audio_path}") + print(f"Prediksi : {label}") + print(f"Keterangan : {description}") + print(f"Confidence : {confidence * 100:.2f}%") + print(f"Probabilitas PD : {probability_pd * 100:.2f}%") + print(f"Probabilitas TPD : {probability_tpd * 100:.2f}%") + + if confidence < CONFIDENCE_THRESHOLD: + print( + "Peringatan : Model belum yakin, suara perlu direkam ulang " + "atau data training perlu ditambah." + ) + + +if __name__ == "__main__": + main() diff --git a/ml/predict_api.py b/ml/predict_api.py new file mode 100644 index 0000000..33e69ed --- /dev/null +++ b/ml/predict_api.py @@ -0,0 +1,162 @@ +from pathlib import Path + +import joblib +import numpy as np + +from audio_utils_api import convert_audio_to_wav +from features import ( + LABEL_PD, + LABEL_TPD, + analyze_audio, + build_prediction_explanation, +) + + +BASE_DIR = Path(__file__).resolve().parent +PROJECT_DIR = BASE_DIR.parent +MODEL_DIR = BASE_DIR / "models" +MODEL_PATH = MODEL_DIR / "svm_voice_confidence_model.joblib" + +CONFIDENCE_THRESHOLD = 0.60 +MODEL_NOT_FOUND_MESSAGE = "Model tidak ditemukan. Pastikan file model berada di folder ml/models/." + +LABEL_DESCRIPTION = { + LABEL_PD: "Percaya Diri", + LABEL_TPD: "Tidak Percaya Diri", +} + + +def find_model_path(): + if MODEL_PATH.exists(): + return MODEL_PATH + + available_models = sorted(MODEL_DIR.glob("*.joblib")) + if available_models: + return available_models[0] + + raise FileNotFoundError(MODEL_NOT_FOUND_MESSAGE) + + +def load_prediction_model(model_path=None): + model_path = Path(model_path) if model_path else find_model_path() + if not model_path.exists(): + raise FileNotFoundError(MODEL_NOT_FOUND_MESSAGE) + return joblib.load(model_path) + + +def get_expected_feature_count(model): + if hasattr(model, "named_steps") and "scaler" in model.named_steps: + return getattr(model.named_steps["scaler"], "n_features_in_", None) + return getattr(model, "n_features_in_", None) + + +def calculate_indicator_probability(model_probability_pd, indicators): + indicator_pd = ( + 0.15 * indicators["volume_score"] + + 0.35 * indicators["intonation_score"] + + 0.50 * indicators["pause_score"] + ) + + adjusted_pd = 0.80 * model_probability_pd + 0.20 * indicator_pd + + if indicators["pause_score"] < 0.35: + adjusted_pd -= (0.35 - indicators["pause_score"]) * 0.25 + if indicators["volume_score"] < 0.18: + adjusted_pd -= (0.18 - indicators["volume_score"]) * 0.10 + if indicators["intonation_score"] < 0.45: + adjusted_pd -= (0.45 - indicators["intonation_score"]) * 0.15 + + return float(np.clip(adjusted_pd, 0.01, 0.99)), float(np.clip(indicator_pd, 0.0, 1.0)) + + +def predict_audio(audio_path, model_path=None): + model = load_prediction_model(model_path) + wav_path = convert_audio_to_wav(audio_path) + + try: + try: + analysis = analyze_audio(wav_path, validate_quality=True) + except ValueError as error: + fallback_analysis = analyze_audio(wav_path, validate_quality=False) + return { + "is_valid_audio": False, + "error_message": str(error), + "predicted_label": None, + "description": "Audio tidak valid", + "confidence": 0.0, + "probability_pd": 0.0, + "probability_tpd": 0.0, + "margin": 0.0, + "audio_quality": fallback_analysis["audio_quality"], + "voice_indicators": fallback_analysis["voice_indicators"], + "explanation": str(error), + } + + features = analysis["features"] + feature_matrix = np.asarray(features, dtype=np.float32).reshape(1, -1) + + expected_feature_count = get_expected_feature_count(model) + if expected_feature_count and feature_matrix.shape[1] != expected_feature_count: + raise ValueError( + "Jumlah fitur audio tidak sesuai dengan model. " + f"Audio menghasilkan {feature_matrix.shape[1]} fitur, " + f"sedangkan model mengharapkan {expected_feature_count} fitur." + ) + + prediction = model.predict(feature_matrix) + probabilities = model.predict_proba(feature_matrix) + + predicted_label = str(prediction[0]) + class_probabilities = { + str(label): float(probability) + for label, probability in zip(model.classes_, probabilities[0]) + } + model_probability_pd = float(class_probabilities.get(LABEL_PD, 0.0)) + probability_pd, indicator_pd_score = calculate_indicator_probability( + model_probability_pd, + analysis["voice_indicators"], + ) + probability_tpd = float(1.0 - probability_pd) + predicted_label = LABEL_PD if probability_pd >= probability_tpd else LABEL_TPD + confidence = float(max(probability_pd, probability_tpd)) + margin = float(abs(probability_pd - probability_tpd)) + explanation = build_prediction_explanation( + predicted_label, + confidence, + analysis["voice_indicators"], + ) + + return { + "is_valid_audio": True, + "predicted_label": predicted_label, + "label": predicted_label, + "description": LABEL_DESCRIPTION.get(predicted_label, predicted_label), + "confidence": confidence, + "probability_pd": probability_pd, + "probability_tpd": probability_tpd, + "margin": margin, + "probabilities": { + LABEL_PD: probability_pd, + LABEL_TPD: probability_tpd, + }, + "audio_quality": analysis["audio_quality"], + "voice_indicators": analysis["voice_indicators"], + "indicator_pd_score": indicator_pd_score, + "model_probability_pd": model_probability_pd, + "model_probability_tpd": float(class_probabilities.get(LABEL_TPD, 0.0)), + "explanation": explanation, + } + finally: + Path(wav_path).unlink(missing_ok=True) + + +def get_model_info(): + model_path = find_model_path() + model = load_prediction_model(model_path) + + return { + "model_path": str(model_path.relative_to(PROJECT_DIR)), + "model_type": type(model).__name__, + "classes": [str(label) for label in getattr(model, "classes_", [])], + "expected_features": get_expected_feature_count(model), + } diff --git a/ml/requirements.txt b/ml/requirements.txt new file mode 100644 index 0000000..4f35177 --- /dev/null +++ b/ml/requirements.txt @@ -0,0 +1,14 @@ +numpy>=1.23,<2.0 +numba>=0.58,<0.60 +librosa==0.10.2.post1 +scikit-learn>=1.3,<1.6 +joblib>=1.3 +streamlit>=1.31 +soundfile>=0.12 +pydub>=0.25 +imageio-ffmpeg>=0.5 +ffmpeg-python>=0.2 +fastapi +uvicorn +python-multipart +PyMySQL diff --git a/ml/train_model.py b/ml/train_model.py new file mode 100644 index 0000000..68ebdfa --- /dev/null +++ b/ml/train_model.py @@ -0,0 +1,168 @@ +from collections import Counter +from pathlib import Path + +import joblib +from sklearn.metrics import ( + accuracy_score, + balanced_accuracy_score, + classification_report, + confusion_matrix, + f1_score, + precision_score, + recall_score, +) +from sklearn.model_selection import GridSearchCV, StratifiedKFold, cross_val_predict +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.svm import SVC + +from features import LABEL_PD, LABEL_TPD, check_dataset_quality, load_dataset + + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +MODEL_DIR = BASE_DIR / "models" +MODEL_PATH = MODEL_DIR / "svm_voice_confidence_model.joblib" + + +def build_pipeline(): + """ + Pipeline wajib: + 1. StandardScaler + 2. SVM classifier + """ + return Pipeline( + [ + ("scaler", StandardScaler()), + ( + "svm", + SVC( + probability=True, + class_weight="balanced", + random_state=42, + ), + ), + ] + ) + + +def build_cv(y): + label_counts = Counter(y) + min_class_count = min(label_counts.values()) + n_splits = min(5, min_class_count) + + if n_splits < 2: + raise ValueError("Minimal perlu 2 data pada setiap kelas untuk Stratified K-Fold.") + + return StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) + + +def build_grid_search(cv): + param_grid = { + "svm__C": [0.1, 1, 10, 100], + "svm__gamma": ["scale", 0.01, 0.001, 0.0001], + "svm__kernel": ["rbf"], + } + + return GridSearchCV( + estimator=build_pipeline(), + param_grid=param_grid, + scoring="f1_macro", + cv=cv, + n_jobs=1, + refit=True, + verbose=1, + ) + + +def print_wrong_predictions(files, y_true, y_pred): + print("\n=== File yang Salah Prediksi ===") + has_wrong_prediction = False + + for file_path, true_label, predicted_label in zip(files, y_true, y_pred): + if true_label != predicted_label: + has_wrong_prediction = True + print(f"{Path(file_path).name} | {true_label} | {predicted_label}") + + if not has_wrong_prediction: + print("Tidak ada file yang salah prediksi pada cross-validation.") + + +def evaluate_model(model, X, y, files, cv): + """ + Evaluasi memakai prediksi out-of-fold agar lebih realistis untuk dataset kecil. + """ + y_pred = cross_val_predict(model, X, y, cv=cv, n_jobs=1) + labels = [LABEL_PD, LABEL_TPD] + + print("\n=== Evaluasi Cross-Validation ===") + print(f"Accuracy : {accuracy_score(y, y_pred):.4f}") + print(f"Balanced Accuracy : {balanced_accuracy_score(y, y_pred):.4f}") + print(f"Precision Macro : {precision_score(y, y_pred, average='macro', zero_division=0):.4f}") + print(f"Recall Macro : {recall_score(y, y_pred, average='macro', zero_division=0):.4f}") + print(f"F1 Macro : {f1_score(y, y_pred, average='macro', zero_division=0):.4f}") + + print("\n=== Classification Report ===") + print( + classification_report( + y, + y_pred, + labels=labels, + target_names=["PD - Percaya Diri", "TPD - Tidak Percaya Diri"], + zero_division=0, + ) + ) + + print("=== Confusion Matrix ===") + matrix = confusion_matrix(y, y_pred, labels=labels) + print("Urutan label:", labels) + print(matrix) + + print("\n=== Ringkasan Benar/Salah per Kelas ===") + for index, label in enumerate(labels): + total = int(matrix[index].sum()) + correct = int(matrix[index, index]) + wrong = total - correct + print(f"{label}: benar={correct}, salah={wrong}, total={total}") + + print_wrong_predictions(files, y, y_pred) + + +def main(): + print("Mengecek kualitas dataset...") + check_dataset_quality(DATA_DIR) + + print("\nMembaca dataset dan mengekstraksi fitur...") + X, y, files = load_dataset(DATA_DIR) + + print(f"\nTotal data valid: {len(files)}") + print(f"Jumlah fitur per audio: {X.shape[1]}") + print("Distribusi label:", dict(Counter(y))) + + cv = build_cv(y) + grid_search = build_grid_search(cv) + + print("\nMelakukan GridSearchCV SVM dengan scoring f1_macro...") + grid_search.fit(X, y) + + best_model = grid_search.best_estimator_ + + print("\n=== Hasil GridSearchCV ===") + print("Best params:", grid_search.best_params_) + print(f"Best CV f1_macro: {grid_search.best_score_:.4f}") + print("Urutan kelas model:", list(best_model.classes_)) + + evaluate_model(best_model, X, y, files, cv) + + print("\nMelatih ulang model terbaik dengan seluruh dataset...") + best_model.fit(X, y) + print("Urutan kelas model final:", list(best_model.classes_)) + + MODEL_DIR.mkdir(parents=True, exist_ok=True) + joblib.dump(best_model, MODEL_PATH) + + print(f"Model terbaik berhasil disimpan ke: {MODEL_PATH}") + + +if __name__ == "__main__": + main() diff --git a/tools/create_blackbox_docx.py b/tools/create_blackbox_docx.py new file mode 100644 index 0000000..795ff0c --- /dev/null +++ b/tools/create_blackbox_docx.py @@ -0,0 +1,240 @@ +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile +from xml.sax.saxutils import escape + + +OUT = Path("Pengujian_Blackbox_ConfiVoice.docx") + +TITLE = ( + "Klasifikasi Tingkat Percaya Diri Berdasarkan Analisis Suara " + "Menggunakan Pendekatan Machine Learning Berbasis Mobile" +) + +USER_ROWS = [ + ("1", "Halaman Login", "Pengguna membuka aplikasi ConfiVoice.", "Sistem menampilkan form login, tombol daftar akun, dan pengaturan endpoint API."), + ("2", "Halaman Daftar", "Pengguna memilih menu daftar dan mengisi nama lengkap, username, password, serta konfirmasi password.", "Sistem membuat akun baru dan mengarahkan pengguna kembali ke halaman login."), + ("3", "Halaman Login", "Pengguna mengisi username dan password yang valid.", "Sistem berhasil masuk dan mengarahkan pengguna ke halaman utama analisis."), + ("4", "Halaman Utama", "Pengguna mengisi nama siswa dan memilih jenis kelamin.", "Sistem menerima data siswa tanpa menampilkan error."), + ("5", "Halaman Utama", "Pengguna memilih file audio dari perangkat.", "Sistem menampilkan audio yang dipilih dan menampilkan preview gelombang suara."), + ("6", "Halaman Utama", "Pengguna menekan tombol rekam, berbicara, lalu menghentikan rekaman.", "Sistem menyimpan rekaman dalam format audio dan menampilkan preview hasil rekaman."), + ("7", "Halaman Prediksi", "Pengguna menekan tombol Prediksi setelah data siswa dan audio sudah lengkap.", "Sistem mengirim audio ke backend dan menampilkan hasil klasifikasi percaya diri atau tidak percaya diri beserta nilai persentase PD dan TPD."), + ("8", "Halaman Prediksi", "Pengguna menekan tombol Prediksi ketika nama, jenis kelamin, atau audio belum lengkap.", "Sistem menampilkan pesan peringatan agar pengguna melengkapi data terlebih dahulu."), + ("9", "Halaman Hasil Prediksi", "Pengguna menekan tombol Simpan Hasil setelah hasil prediksi muncul.", "Sistem menyimpan hasil analisis ke database dan mengosongkan form untuk pengujian siswa berikutnya."), + ("10", "Halaman Lihat Analisis", "Pengguna membuka menu Lihat Analisis.", "Sistem menampilkan daftar riwayat hasil analisis yang sudah tersimpan berdasarkan data siswa."), + ("11", "Halaman Detail Analisis", "Pengguna memilih salah satu data siswa pada riwayat analisis.", "Sistem menampilkan detail hasil prediksi, jenis kelamin, label prediksi, nilai PD, dan nilai TPD."), + ("12", "Logout", "Pengguna menekan tombol logout.", "Sistem mengakhiri sesi pengguna dan kembali ke halaman login."), +] + +ADMIN_ROWS = [ + ("1", "Halaman Login Admin", "Admin membuka halaman login web admin.", "Sistem menampilkan form masuk admin dan pilihan daftar akun."), + ("2", "Halaman Register Admin", "Admin mengisi nama lengkap, username, password, dan konfirmasi password.", "Sistem membuat akun admin baru dan mengarahkan admin ke halaman login."), + ("3", "Halaman Login Admin", "Admin mengisi username dan password yang valid.", "Sistem berhasil masuk dan mengarahkan admin ke dashboard."), + ("4", "Dashboard Admin", "Admin membuka halaman dashboard.", "Sistem menampilkan statistik total prediksi, jumlah PD, jumlah TPD, rata-rata PD, serta tabel hasil analisis."), + ("5", "Filter Dashboard", "Admin menggunakan pencarian atau filter label pada data prediksi.", "Sistem menampilkan data hasil analisis sesuai kata kunci atau label yang dipilih."), + ("6", "Tambah Data Prediksi", "Admin membuka halaman tambah data dan mengisi form hasil analisis secara manual.", "Sistem menyimpan data prediksi baru ke database."), + ("7", "Edit Data Prediksi", "Admin membuka halaman edit pada salah satu data prediksi dan mengubah isian data.", "Sistem memperbarui data prediksi pada database."), + ("8", "Hapus Data Prediksi", "Admin menekan tombol hapus pada salah satu data prediksi.", "Sistem menampilkan konfirmasi dan menghapus data setelah admin menyetujui."), + ("9", "Detail Siswa", "Admin membuka detail riwayat salah satu siswa.", "Sistem menampilkan kumpulan hasil analisis berdasarkan nama siswa yang dipilih."), + ("10", "Kelola User", "Admin membuka halaman kelola user.", "Sistem menampilkan daftar user dan form untuk menambahkan user."), + ("11", "Edit/Hapus User", "Admin mengubah atau menghapus data user.", "Sistem memperbarui atau menghapus data user dari database."), + ("12", "Logout Admin", "Admin memilih menu logout.", "Sistem menghapus sesi admin dan mengarahkan kembali ke halaman login admin."), +] + +GRID = [550, 2050, 3600, 5050, 1100, 1350] + + +def rpr(bold=False, size=24): + bold_xml = "" if bold else "" + return ( + f"" + f"{bold_xml}" + ) + + +def paragraph(text="", align=None, bold=False, size=24, spacing_after=120, page_break=False): + jc = f"" if align else "" + pb = "" if page_break else "" + ppr = f"{pb}{jc}" + if text == "": + return f"{ppr}" + return ( + f"{ppr}{rpr(bold=bold, size=size)}" + f"{escape(text)}" + ) + + +def run_text(text, bold=False, size=24): + return f"{rpr(bold=bold, size=size)}{escape(text)}" + + +def cell(text="", width=1000, align=None, bold=False, grid_span=None, vmerge=None, center_v=True): + span = f"" if grid_span else "" + merge = "" + if vmerge == "restart": + merge = "" + elif vmerge == "continue": + merge = "" + valign = "" if center_v else "" + p_align = f"" if align else "" + p = ( + f"{p_align}" + f"{run_text(text, bold=bold, size=22)}" + ) + return ( + "" + f"{span}{merge}{valign}" + f"{p}" + "" + ) + + +def row(cells): + return f"{''.join(cells)}" + + +def meta(role): + return "".join( + [ + paragraph(f"Judul : {TITLE}", bold=True, spacing_after=20), + paragraph("Nama : ............................................................", bold=True, spacing_after=20), + paragraph(f"Jabatan : {role}", bold=True, spacing_after=220), + ] + ) + + +def blackbox_table(rows): + grid_xml = "".join(f"" for w in GRID) + table_props = """ + + + + + + + + + + + + + + + + + + + """ + header1 = row( + [ + cell("No.", GRID[0], align="center", bold=True, vmerge="restart"), + cell("Halaman", GRID[1], align="center", bold=True, vmerge="restart"), + cell("Skenario", GRID[2], align="center", bold=True, vmerge="restart"), + cell("Hasil yang Diharapkan", GRID[3], align="center", bold=True, vmerge="restart"), + cell("Keterangan", GRID[4] + GRID[5], align="center", bold=True, grid_span=2), + ] + ) + header2 = row( + [ + cell("", GRID[0], vmerge="continue"), + cell("", GRID[1], vmerge="continue"), + cell("", GRID[2], vmerge="continue"), + cell("", GRID[3], vmerge="continue"), + cell("Berhasil", GRID[4], align="center", bold=True), + cell("Tidak Berhasil", GRID[5], align="center", bold=True), + ] + ) + body = [] + for no, page, scenario, expected in rows: + body.append( + row( + [ + cell(no, GRID[0], align="center"), + cell(page, GRID[1]), + cell(scenario, GRID[2]), + cell(expected, GRID[3]), + cell("✓", GRID[4], align="center", bold=True), + cell("", GRID[5], align="center"), + ] + ) + ) + return f"{table_props}{grid_xml}{header1}{header2}{''.join(body)}" + + +def section(title, role, rows, first=False): + return "".join( + [ + paragraph(title, align="center", bold=True, size=32, spacing_after=360, page_break=not first), + meta(role), + blackbox_table(rows), + paragraph("", spacing_after=240), + ] + ) + + +DOCUMENT_XML = f""" + + + {section("Pengujian Blackbox User", "User/Guru", USER_ROWS, first=True)} + {section("Pengujian Blackbox Web Admin", "Admin", ADMIN_ROWS)} + + + + + + +""" + + +CONTENT_TYPES = """ + + + + + + + +""" + +RELS = """ + + + + + +""" + +DOC_RELS = """ + +""" + +CORE = """ + + Pengujian Blackbox ConfiVoice + Codex + Codex + +""" + +APP = """ + + Codex + +""" + + +with ZipFile(OUT, "w", compression=ZIP_DEFLATED) as docx: + docx.writestr("[Content_Types].xml", CONTENT_TYPES) + docx.writestr("_rels/.rels", RELS) + docx.writestr("word/_rels/document.xml.rels", DOC_RELS) + docx.writestr("word/document.xml", DOCUMENT_XML) + docx.writestr("docProps/core.xml", CORE) + docx.writestr("docProps/app.xml", APP) + +print(OUT) diff --git a/tools/create_dfd_image.py b/tools/create_dfd_image.py new file mode 100644 index 0000000..b66e453 --- /dev/null +++ b/tools/create_dfd_image.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from pathlib import Path +import textwrap + +from PIL import Image, ImageDraw, ImageFont + + +OUT_DIR = Path("docs/gambar") +PNG_PATH = OUT_DIR / "dfd_confivoice.png" +SVG_PATH = OUT_DIR / "dfd_confivoice.svg" + + +def load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: + candidates = [ + "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf", + "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Times New Roman.ttf", + "/Library/Fonts/Arial Bold.ttf" if bold else "/Library/Fonts/Arial.ttf", + ] + for path in candidates: + if path and Path(path).exists(): + return ImageFont.truetype(path, size) + return ImageFont.load_default() + + +FONT_TITLE = load_font(42, bold=True) +FONT_HEAD = load_font(28, bold=True) +FONT_BODY = load_font(24) +FONT_SMALL = load_font(20) + + +def draw_wrapped( + draw: ImageDraw.ImageDraw, + text: str, + box: tuple[int, int, int, int], + font: ImageFont.ImageFont, + fill: str = "#0f172a", + align: str = "center", + line_spacing: int = 6, +) -> None: + x1, y1, x2, y2 = box + max_width = x2 - x1 - 28 + words = text.split() + lines: list[str] = [] + current = "" + for word in words: + trial = f"{current} {word}".strip() + if draw.textbbox((0, 0), trial, font=font)[2] <= max_width: + current = trial + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) + + line_heights = [draw.textbbox((0, 0), line, font=font)[3] for line in lines] + total_h = sum(line_heights) + line_spacing * max(0, len(lines) - 1) + y = y1 + ((y2 - y1) - total_h) / 2 + for line, lh in zip(lines, line_heights): + bbox = draw.textbbox((0, 0), line, font=font) + if align == "center": + x = x1 + ((x2 - x1) - (bbox[2] - bbox[0])) / 2 + else: + x = x1 + 14 + draw.text((x, y), line, font=font, fill=fill) + y += lh + line_spacing + + +def round_rect( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], + fill: str, + outline: str, + width: int = 3, + radius: int = 22, +) -> None: + draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width) + + +def process(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, desc: str) -> None: + round_rect(draw, box, "#e0f2fe", "#0369a1", width=4, radius=28) + x1, y1, x2, y2 = box + draw_wrapped(draw, title, (x1 + 10, y1 + 12, x2 - 10, y1 + 58), FONT_HEAD, "#082f49") + draw.line((x1 + 20, y1 + 70, x2 - 20, y1 + 70), fill="#7dd3fc", width=2) + draw_wrapped(draw, desc, (x1 + 10, y1 + 78, x2 - 10, y2 - 10), FONT_SMALL, "#0f172a") + + +def entity(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, desc: str) -> None: + round_rect(draw, box, "#fef3c7", "#b45309", width=4, radius=16) + x1, y1, x2, y2 = box + draw_wrapped(draw, title, (x1 + 8, y1 + 8, x2 - 8, y1 + 50), FONT_HEAD, "#78350f") + draw_wrapped(draw, desc, (x1 + 12, y1 + 56, x2 - 12, y2 - 8), FONT_SMALL, "#0f172a") + + +def datastore(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], title: str, desc: str) -> None: + x1, y1, x2, y2 = box + draw.rectangle(box, fill="#dcfce7", outline="#15803d", width=4) + draw.line((x1 + 24, y1, x1 + 24, y2), fill="#15803d", width=4) + draw_wrapped(draw, title, (x1 + 32, y1 + 6, x2 - 8, y1 + 44), FONT_HEAD, "#14532d") + draw_wrapped(draw, desc, (x1 + 32, y1 + 46, x2 - 8, y2 - 8), FONT_SMALL, "#0f172a") + + +def arrow(draw: ImageDraw.ImageDraw, start: tuple[int, int], end: tuple[int, int], label: str, bend: int = 0) -> None: + sx, sy = start + ex, ey = end + if bend: + mid = ((sx + ex) // 2, (sy + ey) // 2 + bend) + draw.line((sx, sy, mid[0], mid[1], ex, ey), fill="#334155", width=4, joint="curve") + lx, ly = mid + else: + draw.line((sx, sy, ex, ey), fill="#334155", width=4) + lx, ly = (sx + ex) // 2, (sy + ey) // 2 + # arrow head + import math + + angle = math.atan2(ey - sy, ex - sx) + size = 16 + left = (ex - size * math.cos(angle - math.pi / 6), ey - size * math.sin(angle - math.pi / 6)) + right = (ex - size * math.cos(angle + math.pi / 6), ey - size * math.sin(angle + math.pi / 6)) + draw.polygon([end, left, right], fill="#334155") + + lines = textwrap.wrap(label, width=24) + text = "\n".join(lines) + bbox = draw.multiline_textbbox((0, 0), text, font=FONT_SMALL, spacing=3) + pad = 8 + label_box = (lx - (bbox[2] - bbox[0]) / 2 - pad, ly - (bbox[3] - bbox[1]) / 2 - pad, + lx + (bbox[2] - bbox[0]) / 2 + pad, ly + (bbox[3] - bbox[1]) / 2 + pad) + draw.rounded_rectangle(label_box, radius=8, fill="#ffffff", outline="#cbd5e1", width=2) + draw.multiline_text((label_box[0] + pad, label_box[1] + pad), text, font=FONT_SMALL, fill="#0f172a", spacing=3, align="center") + + +def numbered_arrow( + draw: ImageDraw.ImageDraw, + start: tuple[int, int], + end: tuple[int, int], + number: str, + bend: int = 0, +) -> None: + sx, sy = start + ex, ey = end + if bend: + mid = ((sx + ex) // 2, (sy + ey) // 2 + bend) + draw.line((sx, sy, mid[0], mid[1], ex, ey), fill="#334155", width=4, joint="curve") + lx, ly = mid + else: + draw.line((sx, sy, ex, ey), fill="#334155", width=4) + lx, ly = (sx + ex) // 2, (sy + ey) // 2 + + import math + + angle = math.atan2(ey - sy, ex - sx) + size = 16 + left = (ex - size * math.cos(angle - math.pi / 6), ey - size * math.sin(angle - math.pi / 6)) + right = (ex - size * math.cos(angle + math.pi / 6), ey - size * math.sin(angle + math.pi / 6)) + draw.polygon([end, left, right], fill="#334155") + + r = 18 + draw.ellipse((lx - r, ly - r, lx + r, ly + r), fill="#ffffff", outline="#0f172a", width=3) + draw.text((lx, ly - 1), number, font=FONT_SMALL, fill="#0f172a", anchor="mm") + + +def create_png() -> None: + width, height = 2000, 1420 + img = Image.new("RGB", (width, height), "#f8fafc") + draw = ImageDraw.Draw(img) + draw.text((width / 2, 36), "Data Flow Diagram Level 1 Sistem ConfiVoice", font=FONT_TITLE, fill="#0f172a", anchor="ma") + draw.text((width / 2, 86), "Klasifikasi tingkat percaya diri berdasarkan analisis suara", font=FONT_BODY, fill="#475569", anchor="ma") + + user = (70, 245, 390, 435) + admin = (70, 800, 390, 980) + auth = (560, 155, 930, 335) + predict = (560, 405, 930, 610) + history = (560, 675, 930, 875) + admin_proc = (560, 940, 930, 1135) + users = (1220, 155, 1675, 315) + model = (1220, 390, 1675, 575) + results = (1220, 650, 1725, 835) + + entity(draw, user, "Pengguna Mobile", "Guru/user melakukan login, input siswa, rekam/pilih audio, prediksi, simpan, dan lihat analisis") + entity(draw, admin, "Admin Web", "Admin memantau dashboard, mengelola user, dan mengelola hasil analisis") + process(draw, auth, "1.0 Autentikasi", "Registrasi dan login pengguna melalui endpoint /register dan /login") + process(draw, predict, "2.0 Prediksi Audio", "Validasi audio, preprocessing, ekstraksi fitur, dan klasifikasi PD/TPD") + process(draw, history, "3.0 Simpan & Riwayat", "Menyimpan hasil prediksi dan menampilkan riwayat analisis per siswa") + process(draw, admin_proc, "4.0 Kelola Admin", "Menampilkan dashboard, tambah/edit/hapus data prediksi, dan kelola user") + datastore(draw, users, "D1 users", "Akun user/admin: id, full_name, username, password_hash") + datastore(draw, results, "D2 prediction_results", "Hasil analisis: siswa, gender, label, confidence, probabilitas, indikator suara") + datastore(draw, model, "D3 Model SVM", "svm_voice_confidence_model.joblib + fitur suara dari librosa") + + numbered_arrow(draw, (390, 300), (560, 235), "1") + numbered_arrow(draw, (560, 285), (390, 360), "2") + numbered_arrow(draw, (930, 230), (1220, 230), "3") + numbered_arrow(draw, (1220, 280), (930, 285), "4") + + numbered_arrow(draw, (390, 365), (560, 505), "5") + numbered_arrow(draw, (930, 500), (1220, 485), "6") + numbered_arrow(draw, (1220, 545), (930, 575), "7") + numbered_arrow(draw, (560, 455), (390, 425), "8") + + numbered_arrow(draw, (390, 410), (560, 760), "9", bend=45) + numbered_arrow(draw, (930, 765), (1220, 735), "10") + numbered_arrow(draw, (1220, 800), (930, 835), "11") + numbered_arrow(draw, (560, 835), (390, 875), "12") + + numbered_arrow(draw, (390, 890), (560, 1020), "13") + numbered_arrow(draw, (930, 1030), (1220, 795), "14") + numbered_arrow(draw, (560, 1080), (390, 930), "15") + + legend_x, legend_y = 70, 1165 + draw.text((legend_x, legend_y - 30), "Keterangan alur data:", font=FONT_HEAD, fill="#0f172a") + legend_items = [ + "1 login/registrasi", + "2 status akun", + "3 cek/simpan akun", + "4 data user", + "5 data siswa dan audio", + "6 fitur audio", + "7 label PD/TPD + probabilitas", + "8 hasil prediksi", + "9 simpan/lihat riwayat", + "10 simpan hasil", + "11 riwayat analisis", + "12 data riwayat", + "13 login admin", + "14 kelola hasil", + "15 dashboard", + ] + col1 = legend_items[:8] + col2 = legend_items[8:] + for idx, item in enumerate(col1): + draw.text((legend_x, legend_y + idx * 25), item, font=FONT_SMALL, fill="#334155") + for idx, item in enumerate(col2): + draw.text((legend_x + 500, legend_y + idx * 25), item, font=FONT_SMALL, fill="#334155") + draw.text((1220, 1210), "PD = Percaya Diri, TPD = Tidak Percaya Diri.", font=FONT_SMALL, fill="#475569") + draw.text((1220, 1240), "Database aktif: MySQL/MariaDB confivoice.", font=FONT_SMALL, fill="#475569") + OUT_DIR.mkdir(parents=True, exist_ok=True) + img.save(PNG_PATH) + + +def create_svg() -> None: + # The PNG is the polished deliverable. This SVG is a lightweight fallback/reference. + svg = """ + +Data Flow Diagram Level 1 Sistem ConfiVoice +Klasifikasi tingkat percaya diri berdasarkan analisis suara +Versi PNG berisi diagram lengkap dengan alur data mobile, API, model SVM, database, dan admin web. + +""" + SVG_PATH.write_text(svg, encoding="utf-8") + + +def main() -> None: + create_png() + create_svg() + print(PNG_PATH) + print(SVG_PATH) + + +if __name__ == "__main__": + main() diff --git a/tools/patch_report_ooxml.py b/tools/patch_report_ooxml.py new file mode 100644 index 0000000..4087c57 --- /dev/null +++ b/tools/patch_report_ooxml.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import re +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + +from revise_report_docx_html import build_bab_4_5, build_state_of_art_note + + +INPUT_DOCX = Path("/Users/user/Downloads/belum fix.docx") +OUTPUT_DOCX = Path("Laporan_ConfiVoice_Revisi_Bab4_Bab5_FINAL.docx") +WORK_DIR = Path("docx_work") +XML_PATH = "word/document.xml" + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +NS = {"w": W_NS} +ET.register_namespace("w", W_NS) + + +def qn(tag: str) -> str: + prefix, local = tag.split(":", 1) + if prefix != "w": + raise ValueError(tag) + return f"{{{W_NS}}}{local}" + + +def text_content(element: ET.Element) -> str: + return "".join(element.itertext()) + + +def paragraph_text(paragraph: ET.Element) -> str: + return "".join(t.text or "" for t in paragraph.findall(".//w:t", NS)) + + +def clear_paragraph_runs(paragraph: ET.Element) -> None: + for child in list(paragraph): + if child.tag != qn("w:pPr"): + paragraph.remove(child) + + +def append_run(paragraph: ET.Element, text: str, bold: bool = False) -> None: + run = ET.SubElement(paragraph, qn("w:r")) + if bold: + rpr = ET.SubElement(run, qn("w:rPr")) + ET.SubElement(rpr, qn("w:b")) + t = ET.SubElement(run, qn("w:t")) + t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") + t.text = text + + +def set_paragraph_text(paragraph: ET.Element, text: str, bold: bool = False) -> None: + ppr = paragraph.find("w:pPr", NS) + clear_paragraph_runs(paragraph) + if ppr is None: + ppr = ET.Element(qn("w:pPr")) + paragraph.insert(0, ppr) + append_run(paragraph, text, bold=bold) + + +def make_paragraph(text: str, bold: bool = False, center: bool = False) -> ET.Element: + p = ET.Element(qn("w:p")) + ppr = ET.SubElement(p, qn("w:pPr")) + spacing = ET.SubElement(ppr, qn("w:spacing")) + spacing.set(qn("w:after"), "120") + if center: + jc = ET.SubElement(ppr, qn("w:jc")) + jc.set(qn("w:val"), "center") + append_run(p, text, bold=bold) + return p + + +def make_table(rows: list[list[str]]) -> ET.Element: + tbl = ET.Element(qn("w:tbl")) + tbl_pr = ET.SubElement(tbl, qn("w:tblPr")) + borders = ET.SubElement(tbl_pr, qn("w:tblBorders")) + for side in ["top", "left", "bottom", "right", "insideH", "insideV"]: + border = ET.SubElement(borders, qn(f"w:{side}")) + border.set(qn("w:val"), "single") + border.set(qn("w:sz"), "4") + border.set(qn("w:space"), "0") + border.set(qn("w:color"), "000000") + width = ET.SubElement(tbl_pr, qn("w:tblW")) + width.set(qn("w:w"), "9360") + width.set(qn("w:type"), "dxa") + + col_count = max(len(row) for row in rows) if rows else 1 + col_width = str(max(1200, 9360 // col_count)) + grid = ET.SubElement(tbl, qn("w:tblGrid")) + for _ in range(col_count): + col = ET.SubElement(grid, qn("w:gridCol")) + col.set(qn("w:w"), col_width) + + for row_index, row in enumerate(rows): + tr = ET.SubElement(tbl, qn("w:tr")) + for item in row: + tc = ET.SubElement(tr, qn("w:tc")) + tc_pr = ET.SubElement(tc, qn("w:tcPr")) + tcw = ET.SubElement(tc_pr, qn("w:tcW")) + tcw.set(qn("w:w"), col_width) + tcw.set(qn("w:type"), "dxa") + tc.append(make_paragraph(item, bold=row_index == 0)) + return tbl + + +def html_fragment_to_ooxml(fragment: str) -> list[ET.Element]: + cleaned = re.sub(r"", "", fragment) + root = ET.fromstring(f"{cleaned}") + output: list[ET.Element] = [] + + for child in list(root): + if child.tag == "p": + cls = child.attrib.get("class", "") + txt = text_content(child).strip() + if not txt: + output.append(make_paragraph("")) + continue + is_heading = cls == "p7" or txt.startswith("BAB ") + is_subheading = bool(child.find(".//b") is not None) and not is_heading + output.append(make_paragraph(txt, bold=is_heading or is_subheading, center=is_heading)) + elif child.tag == "ul": + for li in child.iter("li"): + txt = text_content(li).strip() + if txt: + output.append(make_paragraph(f"- {txt}")) + elif child.tag == "table": + rows: list[list[str]] = [] + for tr in child.iter("tr"): + cells = [text_content(td).strip() for td in tr.iter("td")] + if cells: + rows.append(cells) + if rows: + output.append(make_table(rows)) + output.append(make_paragraph("")) + return output + + +def patch_document_xml(xml_bytes: bytes) -> bytes: + root = ET.fromstring(xml_bytes) + body = root.find(".//w:body", NS) + if body is None: + raise RuntimeError("word/body tidak ditemukan") + + for text_node in root.findall(".//w:t", NS): + if text_node.text == "PROPOSAL TUGAS AKHIR": + text_node.text = "LAPORAN TUGAS AKHIR" + + for paragraph in body.findall("w:p", NS): + txt = paragraph_text(paragraph) + if txt.startswith("Disini Peneliti akan mengimplementasikan"): + set_paragraph_text( + paragraph, + "Pada tahap ini, peneliti mengimplementasikan model aplikasi dengan membangun aplikasi mobile " + "menggunakan framework Flutter. Aplikasi ini digunakan untuk merekam atau memilih audio, " + "mengirimkan audio ke backend, serta menampilkan hasil klasifikasi tingkat percaya diri " + "berdasarkan model machine learning yang telah dibuat. Hasil prediksi ditampilkan setelah " + "audio selesai dikirim dan diproses oleh sistem.", + ) + elif txt.startswith("Flowchart sistem ini dimulai dengan Button Lanjut"): + set_paragraph_text( + paragraph, + "Flowchart sistem dimulai dari pengguna membuka aplikasi, kemudian melakukan login atau registrasi. " + "Setelah berhasil masuk, pengguna diarahkan ke halaman utama. Pada halaman utama, pengguna dapat " + "memasukkan nama siswa, merekam audio secara langsung atau memilih file audio yang sudah tersedia. " + "Setelah audio siap, pengguna menekan tombol Prediksi untuk mengirimkan audio ke backend. Backend " + "memproses audio menggunakan model machine learning dan mengembalikan hasil klasifikasi berupa " + "Percaya Diri atau Tidak Percaya Diri beserta persentase probabilitasnya. Setelah hasil prediksi " + "ditampilkan, pengguna dapat menyimpan hasil analisis ke database atau melihat riwayat analisis " + "yang telah tersimpan. Flowchart dari aplikasi untuk pengguna dapat dilihat melalui Gambar 3.3.", + ) + + body_children = list(body) + daftar_pustaka_indexes = [ + index + for index, child in enumerate(body_children) + if child.tag == qn("w:p") and paragraph_text(child).strip() == "DAFTAR PUSTAKA" + ] + if not daftar_pustaka_indexes: + raise RuntimeError("Paragraf DAFTAR PUSTAKA tidak ditemukan") + insert_index = daftar_pustaka_indexes[-1] + + addition = "\n".join([build_bab_4_5(), build_state_of_art_note()]) + elements = html_fragment_to_ooxml(addition) + for offset, element in enumerate(elements): + body.insert(insert_index + offset, element) + + # Refresh list after insertion, then add extra bibliography item after Zalukhu. + for index, child in enumerate(list(body)): + if child.tag == qn("w:p") and paragraph_text(child).startswith("Zalukhu, A., Purba"): + body.insert( + index + 1, + make_paragraph( + "Jain, M., Narayan, S., Balaji, P., Bharath, K. P., Bhowmick, A., Karthik, R., " + "& Muthu, R. K. (2020). Speech Emotion Recognition using Support Vector Machine. " + "arXiv. https://arxiv.org/abs/2002.07590" + ), + ) + break + + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def main() -> None: + OUTPUT_DOCX.unlink(missing_ok=True) + with zipfile.ZipFile(INPUT_DOCX, "r") as zin, zipfile.ZipFile(OUTPUT_DOCX, "w", zipfile.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + data = zin.read(item.filename) + if item.filename == XML_PATH: + data = patch_document_xml(data) + zout.writestr(item, data) + + +if __name__ == "__main__": + main() diff --git a/tools/rebuild_bab4_bab5_ooxml.py b/tools/rebuild_bab4_bab5_ooxml.py new file mode 100644 index 0000000..bc45fb4 --- /dev/null +++ b/tools/rebuild_bab4_bab5_ooxml.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import copy +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + + +INPUT_DOCX = Path("belum_fix_source.docx") +OUTPUT_DOCX = Path("Laporan_ConfiVoice_Bab4_Bab5_Sesuai_Project.docx") +XML_PATH = "word/document.xml" + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +NS = {"w": W_NS} +ET.register_namespace("w", W_NS) + + +def qn(tag: str) -> str: + prefix, local = tag.split(":", 1) + if prefix != "w": + raise ValueError(tag) + return f"{{{W_NS}}}{local}" + + +def paragraph_text(paragraph: ET.Element) -> str: + return "".join(t.text or "" for t in paragraph.findall(".//w:t", NS)).strip() + + +def append_run(paragraph: ET.Element, text: str, bold: bool = False) -> None: + run = ET.SubElement(paragraph, qn("w:r")) + if bold: + rpr = ET.SubElement(run, qn("w:rPr")) + ET.SubElement(rpr, qn("w:b")) + t = ET.SubElement(run, qn("w:t")) + t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve") + t.text = text + + +def paragraph(text: str = "", bold: bool = False, center: bool = False) -> ET.Element: + p = ET.Element(qn("w:p")) + ppr = ET.SubElement(p, qn("w:pPr")) + spacing = ET.SubElement(ppr, qn("w:spacing")) + spacing.set(qn("w:after"), "120") + if center: + jc = ET.SubElement(ppr, qn("w:jc")) + jc.set(qn("w:val"), "center") + append_run(p, text, bold=bold) + return p + + +def heading(text: str) -> ET.Element: + return paragraph(text, bold=True, center=True) + + +def subheading(text: str) -> ET.Element: + return paragraph(text, bold=True) + + +def table(rows: list[list[str]]) -> ET.Element: + tbl = ET.Element(qn("w:tbl")) + tbl_pr = ET.SubElement(tbl, qn("w:tblPr")) + tbl_w = ET.SubElement(tbl_pr, qn("w:tblW")) + tbl_w.set(qn("w:w"), "9360") + tbl_w.set(qn("w:type"), "dxa") + borders = ET.SubElement(tbl_pr, qn("w:tblBorders")) + for side in ["top", "left", "bottom", "right", "insideH", "insideV"]: + border = ET.SubElement(borders, qn(f"w:{side}")) + border.set(qn("w:val"), "single") + border.set(qn("w:sz"), "4") + border.set(qn("w:space"), "0") + border.set(qn("w:color"), "000000") + + col_count = max((len(row) for row in rows), default=1) + col_width = str(max(1100, 9360 // col_count)) + grid = ET.SubElement(tbl, qn("w:tblGrid")) + for _ in range(col_count): + col = ET.SubElement(grid, qn("w:gridCol")) + col.set(qn("w:w"), col_width) + + for row_index, row in enumerate(rows): + tr = ET.SubElement(tbl, qn("w:tr")) + for value in row: + tc = ET.SubElement(tr, qn("w:tc")) + tc_pr = ET.SubElement(tc, qn("w:tcPr")) + tc_w = ET.SubElement(tc_pr, qn("w:tcW")) + tc_w.set(qn("w:w"), col_width) + tc_w.set(qn("w:type"), "dxa") + tc.append(paragraph(value, bold=row_index == 0)) + return tbl + + +def copy_lampiran_drawing(body: ET.Element, label: str) -> ET.Element | None: + children = list(body) + lampiran_index = 0 + for index, child in enumerate(children): + if child.tag == qn("w:p") and paragraph_text(child).upper() == "LAMPIRAN": + lampiran_index = index + break + + for index, child in enumerate(children[lampiran_index:], start=lampiran_index): + if child.tag == qn("w:p") and paragraph_text(child).lower() == label.lower(): + for next_child in children[index + 1 : index + 5]: + if next_child.find(".//w:drawing", NS) is not None: + return copy.deepcopy(next_child) + return None + + +def build_bab45(use_case_drawing: ET.Element | None, flowchart_drawing: ET.Element | None) -> list[ET.Element]: + metrics = [ + ["Keterangan", "Hasil"], + ["Total data valid", "48 audio"], + ["Jumlah data PD", "24 audio"], + ["Jumlah data TPD", "24 audio"], + ["Jumlah fitur per audio", "78 fitur"], + ["Algoritma", "Support Vector Machine kernel RBF"], + ["Best parameter", "C=1, gamma=scale, kernel=rbf"], + ["Best CV f1_macro", "0,7873"], + ["Accuracy", "0,7917 atau 79,17%"], + ["Precision Macro", "0,7937 atau 79,37%"], + ["Recall Macro", "0,7917 atau 79,17%"], + ["F1 Macro", "0,7913 atau 79,13%"], + ] + confusion = [ + ["Kelas Aktual", "Diprediksi PD", "Diprediksi TPD", "Total"], + ["PD", "20", "4", "24"], + ["TPD", "6", "18", "24"], + ] + erd = [ + ["Entitas", "Atribut Utama", "Keterangan"], + [ + "users", + "id, full_name, username, password_hash, created_at", + "Menyimpan data akun pengguna/admin yang digunakan untuk login aplikasi mobile dan admin web.", + ], + [ + "prediction_results", + "id, user_id, student_name, student_gender, predicted_label, confidence, probability_pd, probability_tpd, audio_duration, volume_score, intonation_score, pause_score, created_at", + "Menyimpan hasil analisis suara siswa, nilai probabilitas, indikator suara, dan waktu penyimpanan data.", + ], + ] + blackbox = [ + ["Fitur", "Skenario Pengujian", "Hasil Yang Diharapkan", "Status"], + ["Registrasi", "Pengguna mengisi nama lengkap, username, dan password", "Akun berhasil dibuat", "Berhasil"], + ["Login", "Pengguna memasukkan username dan password yang benar", "Sistem masuk ke halaman utama", "Berhasil"], + ["Rekam Audio", "Pengguna menekan tombol rekam lalu berhenti rekam", "Audio WAV tersimpan dan preview gelombang tampil", "Berhasil"], + ["Pilih Audio", "Pengguna memilih file audio dari perangkat", "Audio dipilih dan siap diprediksi", "Berhasil"], + ["Prediksi", "Pengguna mengirim audio valid ke backend", "Sistem menampilkan hasil PD/TPD, confidence, dan probabilitas", "Berhasil"], + ["Simpan Hasil", "Pengguna menekan Simpan Hasil setelah prediksi valid", "Hasil analisis masuk ke database", "Berhasil"], + ["Lihat Analisis", "Pengguna membuka halaman riwayat", "Data hasil analisis tampil per siswa", "Berhasil"], + ["Admin Web", "Admin membuka dashboard", "Admin dapat melihat, menambah, mengubah, dan menghapus data", "Berhasil"], + ] + + items: list[ET.Element] = [ + heading("BAB 4. HASIL DAN PEMBAHASAN"), + subheading("4.1 Analisis Kebutuhan"), + paragraph( + "Tahap analisis kebutuhan dilakukan untuk mengetahui kebutuhan sistem yang akan dibangun pada penelitian " + "klasifikasi tingkat percaya diri berdasarkan analisis suara. Sistem yang dikembangkan adalah ConfiVoice, " + "yaitu aplikasi berbasis mobile yang terhubung dengan backend machine learning untuk mengolah rekaman suara " + "siswa. Hasil keluaran sistem berupa klasifikasi Percaya Diri (PD) atau Tidak Percaya Diri (TPD), nilai " + "confidence, probabilitas, dan indikator suara." + ), + subheading("4.1.1 Analisis Kebutuhan Pengguna"), + paragraph( + "Pengguna utama pada aplikasi mobile adalah guru atau pengguna yang melakukan proses analisis suara siswa. " + "Pengguna dapat melakukan registrasi, login, memasukkan nama siswa, memilih jenis kelamin, merekam atau " + "memilih audio, menjalankan prediksi, menyimpan hasil analisis, dan melihat riwayat analisis. Selain itu, " + "admin dapat mengakses halaman web untuk melihat dashboard, menambah data manual, mengubah data, menghapus " + "data, serta mengelola user." + ), + subheading("4.1.2 Analisis Kebutuhan Data Audio"), + paragraph( + "Data yang digunakan pada penelitian ini berupa rekaman suara siswa dalam format WAV. Dataset disimpan pada " + "folder ml/data dan terdiri dari dua label, yaitu PD untuk Percaya Diri dan TPD untuk Tidak Percaya Diri. " + "Jumlah data yang digunakan adalah 48 audio, terdiri dari 24 audio PD dan 24 audio TPD. Penamaan file " + "menggunakan penanda _pd dan _tpd agar label dapat dibaca otomatis oleh sistem." + ), + subheading("4.1.3 Analisis Data dan Pembentukan Model"), + paragraph( + "Data audio yang telah dikumpulkan diproses melalui beberapa tahap, yaitu pengecekan kualitas audio, " + "preprocessing, ekstraksi fitur, training model, dan evaluasi model. Model yang digunakan adalah Support " + "Vector Machine (SVM) dengan pipeline StandardScaler dan SVC. StandardScaler digunakan untuk menormalkan " + "fitur, sedangkan SVM digunakan untuk melakukan klasifikasi dua kelas." + ), + subheading("4.1.4 Representasi Data"), + paragraph( + "Setiap file audio direpresentasikan menjadi vektor fitur numerik. Fitur yang digunakan meliputi MFCC, " + "delta MFCC, RMS energy, zero crossing rate, spectral centroid, spectral bandwidth, spectral rolloff, pitch, " + "durasi suara aktif, silence ratio, jumlah jeda, rata-rata durasi jeda, dan speech activity ratio. Total " + "fitur yang dihasilkan dari setiap audio adalah 78 fitur." + ), + subheading("4.1.5 Pembagian Data"), + paragraph( + "Pembagian data dilakukan dengan pendekatan cross-validation menggunakan Stratified K-Fold. Pendekatan ini " + "dipilih karena jumlah dataset masih terbatas, sehingga evaluasi model dapat dilakukan secara lebih seimbang " + "terhadap kelas PD dan TPD. Data tetap dijaga agar distribusi label pada setiap fold tidak terlalu timpang." + ), + subheading("4.1.6 Hasil Perhitungan Model SVM"), + paragraph( + "Perhitungan model dimulai dari fitur audio yang telah diekstraksi, kemudian fitur dinormalisasi menggunakan " + "rumus z = (x - mean) / standard deviation. Setelah itu, SVM menghitung pemisahan kelas menggunakan kernel RBF " + "dengan bentuk K(xi, xj) = exp(-gamma ||xi - xj||^2). Sistem tidak menggunakan entropy dan information gain " + "karena metode yang digunakan bukan Decision Tree, melainkan Support Vector Machine." + ), + paragraph( + "Pada hasil prediksi, sistem menampilkan probability_pd dan probability_tpd. Kelas dengan probabilitas lebih " + "besar akan dipilih sebagai hasil klasifikasi. Sebagai contoh, jika probability_pd sebesar 0,78 dan " + "probability_tpd sebesar 0,22, maka hasil klasifikasi adalah Percaya Diri dengan confidence sebesar 78%." + ), + subheading("4.1.7 Evaluasi Model"), + paragraph("Hasil evaluasi model berdasarkan training dan cross-validation dapat dilihat pada tabel berikut."), + table(metrics), + paragraph( + "Berdasarkan confusion matrix, dari 24 data PD terdapat 20 data yang berhasil diklasifikasikan benar dan 4 " + "data salah klasifikasi. Dari 24 data TPD terdapat 18 data yang berhasil diklasifikasikan benar dan 6 data " + "salah klasifikasi." + ), + table(confusion), + subheading("4.1.8 Integrasi Model"), + paragraph( + "Model terbaik hasil training disimpan dalam file ml/models/svm_voice_confidence_model.joblib. Model tersebut " + "diintegrasikan dengan backend FastAPI melalui endpoint /predict. Ketika aplikasi mobile mengirimkan file " + "audio, backend melakukan validasi format, konversi audio, ekstraksi fitur, prediksi menggunakan model SVM, " + "lalu mengembalikan hasil prediksi ke aplikasi mobile." + ), + subheading("4.2 Perancangan Sistem"), + paragraph( + "Perancangan sistem dibuat untuk menjelaskan alur kerja ConfiVoice dari sisi pengguna, backend, database, dan " + "admin web. Sistem terdiri dari aplikasi mobile Flutter, backend API FastAPI, model machine learning SVM, " + "database MySQL/MariaDB, serta admin web untuk pengelolaan hasil analisis." + ), + subheading("4.2.1 Wireframe"), + paragraph( + "Wireframe aplikasi ConfiVoice dibuat sebagai rancangan awal tampilan dan alur penggunaan sistem sebelum " + "aplikasi diimplementasikan. Wireframe menggambarkan halaman login, registrasi, halaman utama analisis, input " + "nama siswa, pilihan jenis kelamin, fitur rekam atau pilih audio, tombol Prediksi, kartu hasil klasifikasi, " + "tombol Simpan Hasil, dan halaman Lihat Analisis." + ), + subheading("4.2.2 Use Case Diagram"), + paragraph( + "Use case diagram digunakan untuk menggambarkan hubungan antara aktor dengan fitur yang terdapat pada sistem. " + "Aktor pada sistem ini terdiri dari pengguna mobile dan admin. Pengguna mobile dapat registrasi, login, mengisi " + "data siswa, merekam atau memilih audio, melakukan prediksi, menyimpan hasil, dan melihat riwayat analisis. " + "Admin dapat login, melihat dashboard, menambah data manual, mengedit data, menghapus data, dan mengelola user." + ), + ] + if use_case_drawing is not None: + items.append(use_case_drawing) + items.extend( + [ + paragraph("Gambar 4.1 Use Case Diagram Sistem ConfiVoice", center=True), + subheading("4.2.3 Flowchart Mobile"), + paragraph( + "Flowchart mobile menjelaskan alur penggunaan aplikasi dari awal sampai hasil analisis disimpan. Alur " + "dimulai dari pengguna membuka aplikasi, melakukan login atau registrasi, mengisi nama siswa dan jenis " + "kelamin, memilih atau merekam audio, menekan tombol Prediksi, kemudian sistem mengirim audio ke backend. " + "Setelah backend mengembalikan hasil PD atau TPD, pengguna dapat menyimpan hasil analisis ke database." + ), + ] + ) + if flowchart_drawing is not None: + items.append(flowchart_drawing) + items.extend( + [ + paragraph("Gambar 4.2 Flowchart Mobile ConfiVoice", center=True), + subheading("4.2.4 Entity Relationship Diagram (ERD)"), + paragraph( + "ERD digunakan untuk menggambarkan struktur data pada sistem ConfiVoice. Database utama yang digunakan " + "adalah MySQL/MariaDB dengan nama database confivoice. Tabel utama yang digunakan adalah users dan " + "prediction_results. Relasi antara kedua tabel ditunjukkan melalui user_id pada tabel prediction_results " + "yang mengarah ke data pengguna." + ), + table(erd), + subheading("4.2.5 Data Flow Diagram (DFD)"), + paragraph( + "Data Flow Diagram menggambarkan perpindahan data pada sistem. Pengguna mengirimkan data akun, data siswa, " + "dan audio melalui aplikasi mobile. Backend menerima audio melalui endpoint /predict, kemudian memproses " + "audio menggunakan model SVM. Hasil prediksi dikirim kembali ke aplikasi mobile dan dapat disimpan melalui " + "endpoint /predictions. Data yang tersimpan dapat diakses kembali melalui halaman Lihat Analisis dan " + "dashboard admin web." + ), + subheading("4.3 Implementasi"), + paragraph( + "Implementasi sistem dilakukan berdasarkan hasil perancangan. Bagian mobile dibuat menggunakan Flutter, " + "backend dibuat menggunakan FastAPI, model klasifikasi dibuat menggunakan Python dan scikit-learn, sedangkan " + "penyimpanan data menggunakan MySQL/MariaDB." + ), + subheading("4.3.1 Implementasi Aplikasi Mobile"), + paragraph( + "Aplikasi mobile memiliki fitur login, registrasi, input nama siswa, pemilihan jenis kelamin, rekam audio, " + "pilih audio, prediksi suara, simpan hasil, dan lihat analisis. Struktur kode mobile dipisahkan ke beberapa " + "bagian, seperti auth, home, prediction, saved analyses, dan model data agar pengelolaan kode lebih rapi." + ), + subheading("4.3.2 Implementasi Backend API"), + paragraph( + "Backend API dibuat menggunakan FastAPI. Endpoint yang digunakan antara lain /register untuk pendaftaran, " + "/login untuk autentikasi, /predict untuk prediksi audio, /predictions untuk menyimpan dan mengambil hasil, " + "/save sebagai endpoint alternatif penyimpanan, dan /model untuk melihat informasi model." + ), + subheading("4.3.3 Implementasi Database"), + paragraph( + "Database yang digunakan adalah MySQL/MariaDB dengan tabel users dan prediction_results. Tabel users " + "menyimpan data akun, sedangkan prediction_results menyimpan hasil analisis suara siswa, termasuk nama " + "siswa, jenis kelamin, label prediksi, confidence, probability_pd, probability_tpd, indikator suara, dan " + "tanggal penyimpanan." + ), + subheading("4.3.4 Implementasi Admin Web"), + paragraph( + "Admin web dibuat menggunakan FastAPI dengan tampilan HTML. Halaman admin menyediakan fitur login, register " + "admin, dashboard hasil prediksi, filter data, tambah data manual, edit data, hapus data, detail hasil siswa, " + "dan pengelolaan user. Admin web memudahkan pengelola melihat hasil analisis tanpa membuka database secara langsung." + ), + subheading("4.4 Pengujian Sistem"), + paragraph( + "Pengujian sistem dilakukan untuk memastikan setiap fitur berjalan sesuai kebutuhan. Pengujian yang dilakukan " + "meliputi pengujian black box, pengujian model klasifikasi, dan pengujian penerimaan pengguna." + ), + subheading("4.4.1 Pengujian Black Box"), + paragraph("Hasil pengujian black box pada fitur utama sistem dapat dilihat pada tabel berikut."), + table(blackbox), + subheading("4.4.2 Pengujian Model Klasifikasi"), + paragraph( + "Pengujian model klasifikasi dilakukan untuk mengetahui kemampuan SVM dalam membedakan suara PD dan TPD. " + "Berdasarkan hasil evaluasi, model memperoleh accuracy sebesar 79,17% dan F1 macro sebesar 79,13%. Nilai " + "tersebut menunjukkan bahwa model sudah mampu mengenali pola suara dengan cukup baik, namun masih dapat " + "ditingkatkan dengan menambah jumlah dan variasi dataset." + ), + subheading("4.4.3 Pengujian User Acceptance Test (UAT)"), + paragraph( + "Pengujian UAT dilakukan untuk mengetahui apakah aplikasi mudah digunakan dan sudah sesuai kebutuhan pengguna. " + "Aspek yang dinilai meliputi kemudahan login, kemudahan merekam atau memilih audio, kejelasan hasil prediksi, " + "kemudahan menyimpan hasil, kemudahan melihat riwayat analisis, dan kemudahan admin dalam melihat data." + ), + subheading("4.5 Pemeliharaan Sistem"), + paragraph( + "Pemeliharaan sistem dilakukan dengan memperbaiki kesalahan yang ditemukan selama penggunaan, menjaga endpoint " + "API agar tetap sesuai, melakukan backup database, menambah dataset audio apabila diperlukan, dan melakukan " + "training ulang model jika terdapat penambahan data. Pemeliharaan juga dilakukan pada tampilan aplikasi agar " + "tetap mudah digunakan oleh pengguna." + ), + heading("BAB 5. KESIMPULAN DAN SARAN"), + subheading("5.1 Kesimpulan"), + paragraph( + "Berdasarkan hasil perancangan, implementasi, dan pengujian, sistem ConfiVoice berhasil dibangun sebagai aplikasi " + "klasifikasi tingkat percaya diri berdasarkan analisis suara. Sistem ini menggunakan aplikasi mobile Flutter, " + "backend FastAPI, model machine learning SVM, database MySQL/MariaDB, dan admin web." + ), + paragraph( + "Model SVM yang digunakan mampu mengklasifikasikan suara ke dalam dua kelas, yaitu Percaya Diri (PD) dan Tidak " + "Percaya Diri (TPD). Berdasarkan hasil pengujian pada dataset yang digunakan, model memperoleh accuracy sebesar " + "79,17% dan F1 macro sebesar 79,13%." + ), + paragraph( + "Aplikasi mobile dapat digunakan untuk registrasi, login, memasukkan data siswa, merekam atau memilih audio, " + "melakukan prediksi, menyimpan hasil, dan melihat riwayat analisis. Admin web dapat digunakan untuk memantau " + "dan mengelola data hasil analisis yang tersimpan di database." + ), + subheading("5.2 Saran"), + paragraph( + "Saran untuk pengembangan berikutnya adalah menambah jumlah dataset audio dari lebih banyak siswa agar model " + "dapat mengenali variasi suara yang lebih luas. Dataset juga sebaiknya direkam dengan kondisi yang lebih " + "beragam agar model menjadi lebih stabil saat digunakan pada lingkungan nyata." + ), + paragraph( + "Penelitian selanjutnya dapat membandingkan metode SVM dengan algoritma lain seperti Random Forest, K-Nearest " + "Neighbor, atau metode deep learning. Selain itu, aplikasi dapat dikembangkan dengan fitur laporan hasil analisis " + "dalam bentuk PDF, grafik perkembangan siswa, dan pengaturan admin yang lebih lengkap." + ), + paragraph(""), + ] + ) + return items + + +def patch(xml_bytes: bytes) -> bytes: + root = ET.fromstring(xml_bytes) + body = root.find(".//w:body", NS) + if body is None: + raise RuntimeError("Body dokumen tidak ditemukan.") + + use_case_drawing = copy_lampiran_drawing(body, "Use Case") + flowchart_drawing = copy_lampiran_drawing(body, "Flowchart") + + children = list(body) + start = None + end = None + for i, child in enumerate(children): + if child.tag == qn("w:p") and paragraph_text(child).replace(" ", "").startswith("BAB4."): + start = i + if child.tag == qn("w:p") and paragraph_text(child) == "DAFTAR PUSTAKA": + end = i + break + if start is None or end is None or end <= start: + raise RuntimeError("Rentang BAB 4 sampai DAFTAR PUSTAKA tidak ditemukan.") + + for child in children[start:end]: + body.remove(child) + + new_items = build_bab45(use_case_drawing, flowchart_drawing) + insert_at = list(body).index(children[end]) + for offset, item in enumerate(new_items): + body.insert(insert_at + offset, item) + + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def main() -> None: + OUTPUT_DOCX.unlink(missing_ok=True) + with zipfile.ZipFile(INPUT_DOCX, "r") as zin, zipfile.ZipFile(OUTPUT_DOCX, "w", zipfile.ZIP_DEFLATED) as zout: + for info in zin.infolist(): + data = zin.read(info.filename) + if info.filename == XML_PATH: + data = patch(data) + zout.writestr(info, data) + + +if __name__ == "__main__": + main() diff --git a/tools/revise_report_docx_html.py b/tools/revise_report_docx_html.py new file mode 100644 index 0000000..e4cdc40 --- /dev/null +++ b/tools/revise_report_docx_html.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +from html import escape +from pathlib import Path + + +WORK_DIR = Path("docx_work") +SOURCE_HTML = WORK_DIR / "source.html" +REVISED_HTML = WORK_DIR / "confivoice_laporan_revisi.html" + + +def p(text: str, cls: str = "p13") -> str: + return f'

{escape(text)}

' + + +def center(text: str, bold: bool = False) -> str: + body = escape(text) + if bold: + body = f"{body}" + return f'

{body}

' + + +def heading(text: str) -> str: + return center(text, bold=True) + + +def subheading(text: str) -> str: + return f'

{escape(text)}

' + + +def bullets(items: list[str]) -> str: + body = "\n".join(f'
  • {escape(item)}
  • ' for item in items) + return f'
      \n{body}\n
    ' + + +def table(headers: list[str], rows: list[list[str]]) -> str: + def cell(text: str, bold: bool = False) -> str: + body = escape(text) + if bold: + body = f"{body}" + return ( + '' + f'

    {body}

    ' + "" + ) + + html_rows = ["" + "".join(cell(h, True) for h in headers) + ""] + for row in rows: + html_rows.append("" + "".join(cell(item) for item in row) + "") + return ( + '\n' + "\n" + + "\n".join(html_rows) + + "\n\n
    " + ) + + +def build_state_of_art_note() -> str: + rows = [ + [ + "A Deep Audiovisual Approach for Human Confidence Classification", + "Chanda dkk. (2021)", + "Paling dekat dengan topik karena membahas klasifikasi confidence dari sinyal audio/video.", + "https://doi.org/10.3389/fcomp.2021.674533", + ], + [ + "Speech Emotion Recognition Based on Parallel CNN-Attention Networks with Multi-Fold Data Augmentation", + "Bautista dkk. (2022)", + "Relevan untuk pengenalan emosi berbasis suara dan fitur akustik sebagai pembanding machine learning.", + "https://doi.org/10.3390/electronics11233935", + ], + [ + "Deteksi Emosi Berdasarkan Sinyal Suara Manusia Menggunakan Discrete Wavelet Transform (DWT) Dengan Klasifikasi Support Vector Machine (SVM)", + "Putri dkk. (2023)", + "Relevan karena sama-sama menggunakan SVM untuk klasifikasi berbasis sinyal suara.", + "https://doi.org/10.54082/jiki.45", + ], + [ + "Emotion Recognition from Speech using SVM and Random Forest Classifier", + "Wincy Pon Annal dkk. (2022)", + "Bisa dipakai sebagai pembanding metode SVM dan Random Forest pada data suara.", + "https://doi.org/10.36548/jscp.2022.1.005", + ], + [ + "Speech Emotion Recognition using Support Vector Machine", + "Jain dkk. (2020)", + "Tambahan referensi untuk menjelaskan penggunaan fitur energy, pitch, MFCC, dan SVM pada speech analysis.", + "https://arxiv.org/abs/2002.07590", + ], + ] + return "\n".join( + [ + subheading("Saran Referensi State Of The Art"), + p( + "Agar State Of The Art lebih kuat dan dekat dengan project ConfiVoice, " + "peneliti dapat menambahkan beberapa referensi berikut. Referensi yang paling " + "disarankan adalah Chanda dkk. (2021) karena topiknya langsung membahas confidence " + "classification, sedangkan referensi lain dapat digunakan sebagai pembanding pada " + "bagian speech emotion recognition dan klasifikasi suara." + ), + table( + ["Judul Jurnal", "Peneliti", "Alasan Dipakai", "Link"], + rows, + ), + ] + ) + + +def build_bab_4_5() -> str: + black_box_rows = [ + ["Login/Register", "Username dan password diisi", "Sistem masuk ke halaman utama", "Berhasil"], + ["Pilih/Rekam Audio", "File audio atau rekaman suara", "Audio tampil pada halaman utama", "Berhasil"], + ["Prediksi", "Audio valid dikirim ke API", "Sistem menampilkan label PD/TPD dan probabilitas", "Berhasil"], + ["Simpan Hasil", "Hasil prediksi tersedia", "Data tersimpan ke database MySQL", "Berhasil"], + ["Lihat Analisis", "User membuka riwayat", "Riwayat hasil prediksi ditampilkan", "Berhasil"], + ["Admin Web", "Admin login ke dashboard", "Data prediksi dapat dilihat dan dikelola", "Berhasil"], + ] + metric_rows = [ + ["Total data valid", "48 audio"], + ["Jumlah kelas", "2 kelas: PD dan TPD"], + ["Jumlah data PD", "24 audio"], + ["Jumlah data TPD", "24 audio"], + ["Jumlah fitur per audio", "78 fitur"], + ["Best parameter SVM", "C=1, gamma=scale, kernel=rbf"], + ["Best CV f1_macro", "0,7873"], + ["Accuracy", "0,7917 atau 79,17%"], + ["Balanced Accuracy", "0,7917 atau 79,17%"], + ["Precision Macro", "0,7937 atau 79,37%"], + ["Recall Macro", "0,7917 atau 79,17%"], + ["F1 Macro", "0,7913 atau 79,13%"], + ] + confusion_rows = [ + ["PD", "20", "4", "24"], + ["TPD", "6", "18", "24"], + ] + + parts = [ + heading("BAB 4. HASIL DAN PEMBAHASAN"), + subheading("4.1 Analisis Kebutuhan Sistem"), + p( + "Pada tahap ini dilakukan analisis kebutuhan sistem untuk membangun aplikasi " + "klasifikasi tingkat percaya diri berdasarkan analisis suara. Sistem yang dikembangkan " + "diberi nama ConfiVoice. Sistem ini dibuat untuk membantu proses analisis tingkat " + "percaya diri siswa melalui rekaman suara speaking bahasa Inggris. Hasil klasifikasi " + "dibagi menjadi dua kelas, yaitu Percaya Diri (PD) dan Tidak Percaya Diri (TPD)." + ), + subheading("4.1.1 Kebutuhan Pengguna"), + p( + "Pengguna utama sistem adalah siswa atau pengguna aplikasi mobile yang melakukan " + "perekaman atau pemilihan audio untuk dianalisis. Pengguna dapat melakukan registrasi, " + "login, memasukkan nama siswa, memilih atau merekam audio, menjalankan prediksi, " + "menyimpan hasil analisis, dan melihat riwayat analisis. Selain pengguna aplikasi mobile, " + "terdapat admin yang dapat mengakses halaman web untuk melihat hasil prediksi, menambah " + "data manual, mengubah data, menghapus data, serta mengelola user." + ), + subheading("4.1.2 Kebutuhan Data Audio"), + p( + "Data yang digunakan berupa file audio berformat WAV. Dataset pada sistem ini terdiri " + "dari dua label, yaitu PD untuk suara yang menunjukkan percaya diri dan TPD untuk suara " + "yang menunjukkan tidak percaya diri. Pada implementasi project, dataset disimpan pada " + "folder ml/data dengan total 48 file audio, terdiri dari 24 data PD dan 24 data TPD. " + "Penamaan file menggunakan penanda _pd dan _tpd agar sistem dapat membaca label secara otomatis." + ), + subheading("4.1.3 Kebutuhan Perangkat Lunak dan Perangkat Keras"), + p( + "Perangkat lunak yang digunakan meliputi Flutter untuk aplikasi mobile, Python untuk " + "proses machine learning dan backend, FastAPI sebagai layanan API, MySQL/MariaDB sebagai " + "database utama, serta phpMyAdmin untuk memeriksa data. Perangkat keras yang digunakan " + "adalah laptop pengembang dan smartphone untuk menjalankan aplikasi mobile serta melakukan " + "perekaman suara." + ), + subheading("4.2 Pengumpulan dan Pengolahan Data"), + p( + "Pengumpulan data dilakukan dengan menyiapkan rekaman suara siswa yang digunakan sebagai " + "data latih model. Data audio dikumpulkan ke dalam folder dataset dan diberi label sesuai " + "kategori percaya diri atau tidak percaya diri. Setelah data terkumpul, sistem melakukan " + "pengecekan kualitas audio, preprocessing, dan ekstraksi fitur agar data suara dapat " + "digunakan oleh model machine learning." + ), + subheading("4.2.1 Dataset Suara"), + p( + "Dataset suara pada project ConfiVoice berada pada direktori ml/data. File audio yang " + "digunakan memiliki format WAV karena proses training membaca file dengan ekstensi tersebut. " + "Setiap file audio merepresentasikan satu sampel suara. Data dibagi menjadi dua kategori, " + "yaitu PD dan TPD, sehingga model dapat mempelajari perbedaan pola suara antara siswa yang " + "percaya diri dan tidak percaya diri." + ), + subheading("4.2.2 Pelabelan Data"), + p( + "Pelabelan data dilakukan berdasarkan nama file atau folder. File dengan nama yang " + "mengandung _pd dikenali sebagai data Percaya Diri, sedangkan file dengan nama yang " + "mengandung _tpd dikenali sebagai data Tidak Percaya Diri. Pelabelan otomatis ini " + "memudahkan proses training karena sistem dapat membaca kelas data tanpa input manual satu per satu." + ), + subheading("4.2.3 Preprocessing Audio"), + p( + "Preprocessing audio dilakukan untuk menyeragamkan data sebelum masuk ke tahap ekstraksi " + "fitur. Proses ini meliputi pembacaan audio dalam bentuk mono, penggunaan sample rate " + "22050 Hz, pemotongan bagian silence, normalisasi volume, serta pengecekan kualitas audio. " + "Sistem juga mengecek apakah audio terlalu pendek, terlalu pelan, atau mengalami clipping. " + "Audio yang tidak memenuhi kualitas dapat ditandai agar pengguna melakukan perekaman ulang." + ), + subheading("4.2.4 Ekstraksi Fitur Suara"), + p( + "Ekstraksi fitur dilakukan menggunakan library librosa. Fitur yang diambil meliputi MFCC, " + "delta MFCC, RMS energy, zero crossing rate, spectral centroid, spectral bandwidth, spectral " + "rolloff, pitch, durasi suara aktif, durasi silence, rasio silence, jumlah jeda, rata-rata " + "durasi jeda, dan rasio aktivitas bicara. Fitur tersebut digunakan karena dapat merepresentasikan " + "karakteristik suara seperti volume, intonasi, kestabilan energi, dan jeda bicara." + ), + subheading("4.3 Pembentukan Model Machine Learning"), + p( + "Pembentukan model dilakukan menggunakan algoritma Support Vector Machine (SVM). Model " + "dilatih menggunakan fitur suara yang telah diekstraksi dari dataset. Sistem menggunakan " + "pipeline yang terdiri dari StandardScaler dan SVM. StandardScaler digunakan untuk " + "menormalkan nilai fitur, sedangkan SVM digunakan sebagai algoritma klasifikasi." + ), + subheading("4.3.1 Pembagian Kelas Klasifikasi"), + p( + "Kelas klasifikasi pada sistem ini terdiri dari dua kelas, yaitu PD dan TPD. PD merupakan " + "label untuk suara yang diklasifikasikan sebagai Percaya Diri, sedangkan TPD merupakan " + "label untuk suara yang diklasifikasikan sebagai Tidak Percaya Diri. Hasil prediksi ditampilkan " + "dalam bentuk label, deskripsi, confidence, probabilitas PD, dan probabilitas TPD." + ), + subheading("4.3.2 Penerapan Algoritma Support Vector Machine"), + p( + "Algoritma SVM diterapkan untuk mencari batas pemisah terbaik antara data suara yang masuk " + "ke kelas PD dan TPD. Pada project ini, SVM menggunakan kernel RBF dan parameter yang dicari " + "melalui GridSearchCV. Parameter yang diuji meliputi nilai C dan gamma. Pemilihan parameter " + "terbaik dilakukan berdasarkan nilai f1_macro agar evaluasi lebih seimbang terhadap dua kelas." + ), + subheading("4.3.3 Training Model"), + p( + "Training model dilakukan dengan membaca seluruh file WAV pada folder ml/data. Sistem " + "melakukan pengecekan kualitas dataset, ekstraksi fitur, training menggunakan GridSearchCV, " + "lalu menyimpan model terbaik ke file ml/models/svm_voice_confidence_model.joblib. Model " + "yang tersimpan digunakan oleh backend API untuk memprediksi audio dari aplikasi mobile." + ), + subheading("4.3.4 Perhitungan Klasifikasi Model"), + p( + "Perhitungan klasifikasi dimulai dari proses ekstraksi fitur audio. Setiap audio diubah " + "menjadi vektor fitur numerik. Setelah fitur diperoleh, data dinormalisasi menggunakan " + "StandardScaler dengan rumus z = (x - mean) / standard deviation. Normalisasi diperlukan " + "agar setiap fitur memiliki skala yang seimbang sebelum masuk ke model SVM." + ), + p( + "Model SVM kemudian menghitung kemiripan antara data uji dan data latih menggunakan kernel " + "RBF dengan bentuk K(xi, xj) = exp(-gamma ||xi - xj||^2). Hasil perhitungan digunakan untuk " + "menentukan apakah suara termasuk kelas PD atau TPD. Sistem juga menghasilkan nilai " + "probabilitas PD dan TPD. Kelas dengan probabilitas terbesar dipilih sebagai hasil prediksi, " + "sedangkan nilai probabilitas terbesar digunakan sebagai confidence." + ), + p( + "Sebagai contoh, jika hasil prediksi menghasilkan probability_pd sebesar 0,78 dan " + "probability_tpd sebesar 0,22, maka sistem mengklasifikasikan audio sebagai Percaya Diri " + "dengan confidence sebesar 78%. Perhitungan pada project ini tidak menggunakan entropy dan " + "information gain karena algoritma yang digunakan bukan Decision Tree, melainkan Support Vector Machine." + ), + subheading("4.3.5 Evaluasi Model"), + p( + "Evaluasi model dilakukan menggunakan cross-validation. Metrik evaluasi yang digunakan " + "meliputi accuracy, balanced accuracy, precision macro, recall macro, f1 macro, classification " + "report, dan confusion matrix. Berdasarkan hasil training pada dataset project, diperoleh " + "hasil evaluasi sebagai berikut." + ), + table(["Keterangan", "Hasil"], metric_rows), + p( + "Confusion matrix menunjukkan bahwa dari 24 data PD terdapat 20 data yang berhasil " + "diprediksi benar dan 4 data salah prediksi. Dari 24 data TPD terdapat 18 data yang " + "berhasil diprediksi benar dan 6 data salah prediksi." + ), + table(["Kelas Aktual", "Diprediksi PD", "Diprediksi TPD", "Total"], confusion_rows), + subheading("4.4 Perancangan Sistem"), + p( + "Perancangan sistem dilakukan untuk menggambarkan alur kerja aplikasi, hubungan pengguna " + "dengan sistem, dan struktur data yang digunakan. Sistem ConfiVoice terdiri dari aplikasi " + "mobile, backend API, model machine learning, database, dan admin web. Aplikasi mobile " + "digunakan untuk input audio dan melihat hasil prediksi, backend API digunakan untuk memproses " + "audio, database digunakan untuk menyimpan hasil analisis, sedangkan admin web digunakan " + "untuk mengelola dan memantau data." + ), + subheading("4.4.1 Use Case Diagram"), + p( + "Use case sistem melibatkan dua aktor utama, yaitu pengguna dan admin. Pengguna dapat " + "melakukan registrasi, login, memasukkan nama siswa, merekam atau memilih audio, melakukan " + "prediksi, menyimpan hasil prediksi, dan melihat riwayat analisis. Admin dapat login ke " + "halaman admin, melihat dashboard hasil prediksi, menambah data manual, mengedit data, " + "menghapus data, dan mengelola user." + ), + subheading("4.4.2 Flowchart Sistem"), + p( + "Alur sistem dimulai dari pengguna membuka aplikasi, kemudian melakukan login atau registrasi. " + "Setelah berhasil masuk, pengguna diarahkan ke halaman utama. Pada halaman utama, pengguna " + "memasukkan nama siswa, merekam audio secara langsung atau memilih file audio yang tersedia. " + "Setelah audio siap, pengguna menekan tombol Prediksi untuk mengirimkan audio ke backend. " + "Backend memproses audio menggunakan model machine learning dan mengembalikan hasil klasifikasi " + "berupa Percaya Diri atau Tidak Percaya Diri beserta persentase probabilitasnya. Hasil tidak " + "ditampilkan sebagai grafik persentase yang naik turun saat merekam, tetapi ditampilkan langsung " + "setelah audio selesai diproses oleh sistem." + ), + subheading("4.4.3 Entity Relationship Diagram"), + p( + "Database sistem menggunakan tabel utama prediction_results dan users. Tabel users digunakan " + "untuk menyimpan data akun pengguna, sedangkan tabel prediction_results digunakan untuk " + "menyimpan hasil analisis suara. Data yang disimpan meliputi nama siswa, jenis kelamin, label " + "prediksi, deskripsi, confidence, probabilitas PD, probabilitas TPD, status valid audio, durasi " + "audio, volume score, intonation score, pause score, speech activity ratio, silence ratio, dan waktu penyimpanan." + ), + subheading("4.4.4 Data Flow Diagram"), + p( + "Data flow sistem dimulai dari input audio pada aplikasi mobile. Audio dikirim ke endpoint " + "/predict pada backend FastAPI. Backend melakukan konversi audio, validasi kualitas, preprocessing, " + "ekstraksi fitur, dan prediksi menggunakan model SVM. Hasil prediksi dikirim kembali ke aplikasi " + "mobile. Jika pengguna menekan tombol Simpan Hasil, data dikirim ke endpoint /predictions dan " + "disimpan ke database MySQL. Admin web mengambil data dari database untuk ditampilkan pada dashboard." + ), + subheading("4.4.5 Perancangan Antarmuka"), + p( + "Antarmuka aplikasi mobile dirancang sederhana agar mudah digunakan oleh siswa. Tampilan utama " + "berisi input nama siswa, pilihan rekam atau pilih audio, tombol Prediksi, tombol Simpan Hasil, " + "dan akses menuju halaman Lihat Analisis. Tema aplikasi menggunakan warna gelap dengan kombinasi " + "navy dan biru. Admin web dirancang sebagai dashboard untuk menampilkan jumlah data, jumlah hasil " + "percaya diri, jumlah hasil tidak percaya diri, rata-rata skor percaya diri, serta tabel hasil analisis." + ), + subheading("4.5 Implementasi Sistem"), + p( + "Implementasi sistem dilakukan dengan membangun aplikasi mobile menggunakan Flutter, backend " + "menggunakan FastAPI, model klasifikasi menggunakan Python dan scikit-learn, serta database " + "menggunakan MySQL. Setiap komponen saling terhubung melalui API sehingga aplikasi mobile dapat " + "mengirim audio, menerima hasil prediksi, menyimpan hasil, dan melihat riwayat analisis." + ), + subheading("4.5.1 Implementasi Aplikasi Mobile"), + p( + "Aplikasi mobile dibuat menggunakan Flutter. Fitur utama aplikasi meliputi login, registrasi, " + "input nama siswa, rekam audio, pilih audio, prediksi suara, simpan hasil, dan lihat analisis. " + "Aplikasi juga menyimpan endpoint API agar pengguna tidak perlu mengubah alamat IP secara berulang. " + "Setelah data berhasil disimpan, form input dan audio akan dikosongkan kembali agar siap digunakan " + "untuk siswa berikutnya." + ), + subheading("4.5.2 Implementasi Backend API"), + p( + "Backend API dibuat menggunakan FastAPI. Endpoint utama yang digunakan adalah /predict untuk " + "memproses audio, /predictions untuk menyimpan dan mengambil hasil prediksi, /save sebagai endpoint " + "alternatif penyimpanan, /login untuk autentikasi, /register untuk registrasi, dan /model untuk melihat " + "informasi model. Backend juga menerapkan CORS agar dapat diakses oleh aplikasi mobile." + ), + subheading("4.5.3 Implementasi Database"), + p( + "Database utama yang digunakan adalah MySQL/MariaDB dengan nama database confivoice. Tabel utama " + "yang digunakan adalah prediction_results untuk menyimpan hasil analisis dan users untuk menyimpan " + "akun pengguna. Database ini dapat dilihat melalui phpMyAdmin sehingga admin lebih mudah memeriksa " + "data hasil prediksi." + ), + subheading("4.5.4 Implementasi Admin Web"), + p( + "Admin web dibuat menggunakan FastAPI dengan tampilan HTML. Halaman admin menyediakan fitur login, " + "register admin, dashboard hasil prediksi, filter data, tambah data manual, edit data, hapus data, " + "detail riwayat siswa, dan pengelolaan user. Admin web membantu pihak pengelola melihat seluruh hasil " + "analisis dari aplikasi mobile tanpa harus membuka database secara langsung." + ), + subheading("4.6 Pengujian Sistem"), + p( + "Pengujian dilakukan untuk memastikan fitur aplikasi berjalan sesuai kebutuhan. Pengujian meliputi " + "pengujian black box pada fitur aplikasi, pengujian model klasifikasi, dan pengujian penerimaan pengguna. " + "Pengujian dilakukan pada alur utama, yaitu login, input nama siswa, rekam atau pilih audio, prediksi, " + "simpan hasil, lihat analisis, dan akses admin web." + ), + subheading("4.6.1 Pengujian Black Box"), + p( + "Pengujian black box dilakukan dengan mencoba setiap fitur berdasarkan input dan output yang diharapkan. " + "Hasil pengujian black box dapat dilihat pada tabel berikut." + ), + table(["Fitur", "Skenario", "Hasil Yang Diharapkan", "Status"], black_box_rows), + subheading("4.6.2 Pengujian Model Klasifikasi"), + p( + "Pengujian model dilakukan untuk melihat performa SVM dalam membedakan suara PD dan TPD. Model diuji " + "menggunakan cross-validation dan confusion matrix. Hasil pengujian menunjukkan accuracy sebesar 79,17% " + "dan F1 macro sebesar 79,13%. Nilai tersebut menunjukkan bahwa model sudah dapat mengenali pola suara " + "dengan cukup baik, namun masih dapat ditingkatkan dengan menambah jumlah dan variasi dataset." + ), + subheading("4.6.3 Pengujian User Acceptance Test (UAT)"), + p( + "Pengujian UAT dilakukan untuk mengetahui apakah aplikasi mudah digunakan dan sesuai kebutuhan pengguna. " + "Aspek yang diuji meliputi kemudahan login, kemudahan merekam suara, kejelasan hasil prediksi, kemudahan " + "menyimpan hasil, kemudahan melihat riwayat analisis, dan tampilan aplikasi. Hasil UAT dapat digunakan " + "sebagai dasar perbaikan antarmuka dan alur penggunaan aplikasi." + ), + subheading("4.7 Pembahasan Hasil"), + p( + "Berdasarkan implementasi yang telah dilakukan, sistem ConfiVoice mampu melakukan klasifikasi tingkat " + "percaya diri berdasarkan audio suara. Sistem tidak hanya memberikan label PD atau TPD, tetapi juga " + "menampilkan confidence, probabilitas masing-masing kelas, dan penjelasan berdasarkan indikator suara. " + "Aplikasi mobile memudahkan pengguna melakukan analisis secara langsung setelah audio diproses, sedangkan " + "admin web membantu pengelola melihat dan mengelola data hasil prediksi. Dengan integrasi Flutter, FastAPI, " + "model SVM, dan MySQL, sistem dapat digunakan sebagai alat bantu analisis kepercayaan diri berbasis suara." + ), + heading("BAB 5. KESIMPULAN DAN SARAN"), + subheading("5.1 Kesimpulan"), + p( + "Berdasarkan hasil implementasi dan pembahasan, dapat disimpulkan bahwa sistem ConfiVoice berhasil " + "dibangun sebagai aplikasi klasifikasi tingkat percaya diri berdasarkan analisis suara. Sistem ini " + "menggunakan fitur audio seperti MFCC, pitch, energi suara, spectral features, dan jeda bicara untuk " + "merepresentasikan karakteristik suara pengguna." + ), + p( + "Model klasifikasi dibangun menggunakan algoritma Support Vector Machine dengan dua kelas keluaran, " + "yaitu Percaya Diri dan Tidak Percaya Diri. Model dilatih menggunakan dataset audio berformat WAV yang " + "telah diberi label PD dan TPD. Berdasarkan hasil evaluasi, model memperoleh accuracy sebesar 79,17% " + "dan F1 macro sebesar 79,13%." + ), + p( + "Aplikasi mobile berbasis Flutter telah terintegrasi dengan backend FastAPI dan database MySQL. Pengguna " + "dapat melakukan login, merekam atau memilih audio, menjalankan prediksi, menyimpan hasil, dan melihat " + "riwayat analisis. Admin juga dapat mengelola data hasil prediksi melalui halaman web admin." + ), + subheading("5.2 Saran"), + p( + "Saran untuk pengembangan berikutnya adalah menambah jumlah dataset audio agar model dapat mengenali " + "variasi suara yang lebih luas. Dataset sebaiknya dikumpulkan dari lebih banyak siswa dengan kondisi " + "rekaman yang berbeda agar model menjadi lebih stabil." + ), + p( + "Selain itu, penelitian selanjutnya dapat membandingkan algoritma SVM dengan metode lain seperti Random " + "Forest, K-Nearest Neighbor, atau Deep Learning untuk mengetahui metode yang paling sesuai. Aplikasi juga " + "dapat dikembangkan dengan fitur laporan hasil analisis dalam bentuk PDF atau grafik perkembangan siswa " + "agar lebih bermanfaat bagi guru atau pihak sekolah." + ), + p(""), + ] + return "\n".join(parts) + + +def main() -> None: + html = SOURCE_HTML.read_text(encoding="utf-8") + html = html.replace("PROPOSAL TUGAS AKHIR", "LAPORAN TUGAS AKHIR") + + html = html.replace( + "

    Disini Peneliti akan mengimplementasikan model aplikasi dengan membuat aplikasi berbasis android dengan menggunakan flutter. Peneliti juga akan mengimplementasikan Outputnya melalui proses audio setelah user menekan tombol prediksi.

    ", + p( + "Pada tahap ini, peneliti mengimplementasikan model aplikasi dengan membangun aplikasi mobile " + "menggunakan framework Flutter. Aplikasi ini digunakan untuk merekam atau memilih audio, " + "mengirimkan audio ke backend, serta menampilkan hasil klasifikasi tingkat percaya diri " + "berdasarkan model machine learning yang telah dibuat. Hasil prediksi ditampilkan setelah " + "audio selesai dikirim dan diproses oleh sistem." + ), + ) + + html = html.replace( + "

    Flowchart sistem ini dimulai dengan Button Lanjut yang mana akan melanjutkan pada Halaman Utama, disini pengguna dapat melakukan proses tes Klasifikasi dengan cara start lalu mulai me record suaranya sendiri yang akan muncul output Kepercayaan Diri dengan presentase real-time, disaat pengguna mematikan tombol record maka presentase akan 0% (tidak mendeteksi suara). Flowchart dari Aplikasi untuk pengguna dapat dilihat melalui Gambar 3.3.

    ", + p( + "Flowchart sistem dimulai dari pengguna membuka aplikasi, kemudian melakukan login atau registrasi. " + "Setelah berhasil masuk, pengguna diarahkan ke halaman utama. Pada halaman utama, pengguna dapat " + "memasukkan nama siswa, merekam audio secara langsung atau memilih file audio yang sudah tersedia. " + "Setelah audio siap, pengguna menekan tombol Prediksi untuk mengirimkan audio ke backend. Backend " + "memproses audio menggunakan model machine learning dan mengembalikan hasil klasifikasi berupa " + "Percaya Diri atau Tidak Percaya Diri beserta persentase probabilitasnya. Setelah hasil prediksi " + "ditampilkan, pengguna dapat menyimpan hasil analisis ke database atau melihat riwayat analisis " + "yang telah tersimpan. Flowchart dari aplikasi untuk pengguna dapat dilihat melalui Gambar 3.3." + ), + ) + + marker = '

    DAFTAR PUSTAKA

    ' + insert_at = html.rfind(marker) + if insert_at == -1: + raise RuntimeError("Marker DAFTAR PUSTAKA tidak ditemukan.") + + addition = "\n".join([build_bab_4_5(), build_state_of_art_note(), ""]) + html = html[:insert_at] + addition + html[insert_at:] + + bibliography_marker = '

    Zalukhu, A., Purba, S., & Darma, D. (2023). Perangkat Lunak Aplikasi Pembelajaran Flowchart. 4(1).

    ' + extra_bibliography = "\n".join( + [ + bibliography_marker, + p( + "Jain, M., Narayan, S., Balaji, P., Bharath, K. P., Bhowmick, A., Karthik, R., & Muthu, R. K. (2020). " + "Speech Emotion Recognition using Support Vector Machine. arXiv. https://arxiv.org/abs/2002.07590" + ), + ] + ) + html = html.replace(bibliography_marker, extra_bibliography) + + REVISED_HTML.write_text(html, encoding="utf-8") + + +if __name__ == "__main__": + main()