Menambahkan project TA
This commit is contained in:
commit
e0839a7237
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="confivoice"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="confivoice"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
class LoginScreen extends StatefulWidget {
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class LoginScreen extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class LoginScreen extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
|
||||||
|
|
@ -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<String, dynamic> json) {
|
||||||
|
return UserSession(
|
||||||
|
id:
|
||||||
|
json['id'] is int
|
||||||
|
? json['id'] as int
|
||||||
|
: int.parse(json['id'].toString()),
|
||||||
|
fullName: json['full_name']?.toString() ?? '',
|
||||||
|
username: json['username']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String fullName;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'full_name': fullName, 'username': username};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthGate extends StatefulWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthGate> createState() => _AuthGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthGateState extends State<AuthGate> {
|
||||||
|
static const _sessionPreferenceKey = 'confivoice_user_session';
|
||||||
|
|
||||||
|
UserSession? _session;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSession() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final sessionJson = preferences.getString(_sessionPreferenceKey);
|
||||||
|
if (sessionJson != null && sessionJson.isNotEmpty) {
|
||||||
|
_session = UserSession.fromJson(
|
||||||
|
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSession(UserSession session) async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_sessionPreferenceKey,
|
||||||
|
jsonEncode(session.toJson()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Berhasil masuk, ${session.fullName}',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.remove(_sessionPreferenceKey);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(context, message: 'Berhasil logout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return AuthPage(onAuthenticated: _setSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfiVoiceShell(session: session, onLogout: _logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPage extends StatefulWidget {
|
||||||
|
const AuthPage({super.key, required this.onAuthenticated});
|
||||||
|
|
||||||
|
final ValueChanged<UserSession> onAuthenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthPageState extends State<AuthPage> {
|
||||||
|
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
|
||||||
|
|
||||||
|
final TextEditingController _apiController = TextEditingController(
|
||||||
|
text: defaultApiEndpoint(),
|
||||||
|
);
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadApiEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_apiController.dispose();
|
||||||
|
_fullNameController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadApiEndpoint() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final endpoint = normalizeApiEndpoint(
|
||||||
|
preferences.getString(_apiEndpointPreferenceKey) ?? '',
|
||||||
|
);
|
||||||
|
if (endpoint.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_apiController.text = endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveApiEndpoint() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_apiEndpointPreferenceKey,
|
||||||
|
_apiController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _authEndpoint(String path) {
|
||||||
|
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
|
||||||
|
if (predictEndpoint == null ||
|
||||||
|
!predictEndpoint.hasScheme ||
|
||||||
|
predictEndpoint.host.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return predictEndpoint.replace(path: path, queryParameters: {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Endpoint API tidak valid.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
final confirmPassword = _confirmPasswordController.text;
|
||||||
|
final fullName = _fullNameController.text.trim();
|
||||||
|
if (username.isEmpty ||
|
||||||
|
password.isEmpty ||
|
||||||
|
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister && password != confirmPassword) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Konfirmasi password belum sama.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (_isRegister) 'full_name': fullName,
|
||||||
|
};
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
endpoint,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final detail =
|
||||||
|
decoded is Map ? decoded['detail']?.toString() : response.body;
|
||||||
|
throw Exception(detail ?? response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveApiEndpoint();
|
||||||
|
if (_isRegister) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final registeredUsername = username;
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_fullNameController.clear();
|
||||||
|
_usernameController.text = registeredUsername;
|
||||||
|
_passwordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Registrasi berhasil. Silakan login.',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final user = UserSession.fromJson(
|
||||||
|
(decoded['user'] as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
widget.onAuthenticated(user);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage =
|
||||||
|
_isRegister
|
||||||
|
? _cleanAuthErrorMessage(error)
|
||||||
|
: 'Username atau password salah.';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanAuthErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
const exceptionPrefix = 'Exception: ';
|
||||||
|
return message.startsWith(exceptionPrefix)
|
||||||
|
? message.substring(exceptionPrefix.length)
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _authFieldDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFF536179)),
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeSwitch() {
|
||||||
|
return Container(
|
||||||
|
height: 46,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFD6E3F7)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment:
|
||||||
|
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 0.5,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF102A56).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Masuk',
|
||||||
|
isSelected: !_isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Daftar',
|
||||||
|
isSelected: _isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 270,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_isRegister
|
||||||
|
? Column(
|
||||||
|
key: const ValueKey('register-form'),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _fullNameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Nama lengkap',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
label: 'Konfirmasi password',
|
||||||
|
icon: Icons.lock_reset_outlined,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
key: const ValueKey('login-form'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.graphic_eq_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 42,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Confivoice',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.20),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildModeSwitch(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_buildAuthForm(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? 'Daftar' : 'Masuk',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AuthErrorBox(message: _errorMessage!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeButton extends StatelessWidget {
|
||||||
|
const _ModeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : const Color(0xFF0F172A),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.decorationBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool obscureText;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final InputDecoration Function({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
})
|
||||||
|
decorationBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: decorationBuilder(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthErrorBox extends StatelessWidget {
|
||||||
|
const _AuthErrorBox({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, dynamic> json) {
|
||||||
|
return UserSession(
|
||||||
|
id:
|
||||||
|
json['id'] is int
|
||||||
|
? json['id'] as int
|
||||||
|
: int.parse(json['id'].toString()),
|
||||||
|
fullName: json['full_name']?.toString() ?? '',
|
||||||
|
username: json['username']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String fullName;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'full_name': fullName, 'username': username};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthGate extends StatefulWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthGate> createState() => _AuthGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthGateState extends State<AuthGate> {
|
||||||
|
static const _sessionPreferenceKey = 'confivoice_user_session';
|
||||||
|
|
||||||
|
UserSession? _session;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSession() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final sessionJson = preferences.getString(_sessionPreferenceKey);
|
||||||
|
if (sessionJson != null && sessionJson.isNotEmpty) {
|
||||||
|
_session = UserSession.fromJson(
|
||||||
|
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSession(UserSession session) async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_sessionPreferenceKey,
|
||||||
|
jsonEncode(session.toJson()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Berhasil masuk, ${session.fullName}',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.remove(_sessionPreferenceKey);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(context, message: 'Berhasil logout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return AuthPage(onAuthenticated: _setSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfiVoiceShell(session: session, onLogout: _logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPage extends StatefulWidget {
|
||||||
|
const AuthPage({super.key, required this.onAuthenticated});
|
||||||
|
|
||||||
|
final ValueChanged<UserSession> onAuthenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthPageState extends State<AuthPage> {
|
||||||
|
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
|
||||||
|
|
||||||
|
final TextEditingController _apiController = TextEditingController(
|
||||||
|
text: defaultApiEndpoint(),
|
||||||
|
);
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadApiEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_apiController.dispose();
|
||||||
|
_fullNameController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadApiEndpoint() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final endpoint = normalizeApiEndpoint(
|
||||||
|
preferences.getString(_apiEndpointPreferenceKey) ?? '',
|
||||||
|
);
|
||||||
|
if (endpoint.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_apiController.text = endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveApiEndpoint() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_apiEndpointPreferenceKey,
|
||||||
|
_apiController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _authEndpoint(String path) {
|
||||||
|
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
|
||||||
|
if (predictEndpoint == null ||
|
||||||
|
!predictEndpoint.hasScheme ||
|
||||||
|
predictEndpoint.host.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return predictEndpoint.replace(path: path, queryParameters: {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Endpoint API tidak valid.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
final confirmPassword = _confirmPasswordController.text;
|
||||||
|
final fullName = _fullNameController.text.trim();
|
||||||
|
if (username.isEmpty ||
|
||||||
|
password.isEmpty ||
|
||||||
|
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister && password != confirmPassword) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Konfirmasi password belum sama.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (_isRegister) 'full_name': fullName,
|
||||||
|
};
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
endpoint,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final detail =
|
||||||
|
decoded is Map ? decoded['detail']?.toString() : response.body;
|
||||||
|
throw Exception(detail ?? response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveApiEndpoint();
|
||||||
|
if (_isRegister) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final registeredUsername = username;
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_fullNameController.clear();
|
||||||
|
_usernameController.text = registeredUsername;
|
||||||
|
_passwordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Registrasi berhasil. Silakan login.',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final user = UserSession.fromJson(
|
||||||
|
(decoded['user'] as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
widget.onAuthenticated(user);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage =
|
||||||
|
_isRegister
|
||||||
|
? _cleanAuthErrorMessage(error)
|
||||||
|
: 'Username atau password salah.';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanAuthErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
const exceptionPrefix = 'Exception: ';
|
||||||
|
return message.startsWith(exceptionPrefix)
|
||||||
|
? message.substring(exceptionPrefix.length)
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _authFieldDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFF536179)),
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeSwitch() {
|
||||||
|
return Container(
|
||||||
|
height: 46,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFD6E3F7)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment:
|
||||||
|
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 0.5,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF102A56).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Masuk',
|
||||||
|
isSelected: !_isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Daftar',
|
||||||
|
isSelected: _isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 270,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_isRegister
|
||||||
|
? Column(
|
||||||
|
key: const ValueKey('register-form'),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _fullNameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Nama lengkap',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
label: 'Konfirmasi password',
|
||||||
|
icon: Icons.lock_reset_outlined,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
key: const ValueKey('login-form'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.graphic_eq_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 42,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Confivoice',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.20),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildModeSwitch(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_buildAuthForm(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? 'Daftar' : 'Masuk',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AuthErrorBox(message: _errorMessage!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeButton extends StatelessWidget {
|
||||||
|
const _ModeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : const Color(0xFF0F172A),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.decorationBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool obscureText;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final InputDecoration Function({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
})
|
||||||
|
decorationBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: decorationBuilder(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthErrorBox extends StatelessWidget {
|
||||||
|
const _AuthErrorBox({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, dynamic> json) {
|
||||||
|
return UserSession(
|
||||||
|
id:
|
||||||
|
json['id'] is int
|
||||||
|
? json['id'] as int
|
||||||
|
: int.parse(json['id'].toString()),
|
||||||
|
fullName: json['full_name']?.toString() ?? '',
|
||||||
|
username: json['username']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String fullName;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'full_name': fullName, 'username': username};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthGate extends StatefulWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthGate> createState() => _AuthGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthGateState extends State<AuthGate> {
|
||||||
|
static const _sessionPreferenceKey = 'confivoice_user_session';
|
||||||
|
|
||||||
|
UserSession? _session;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSession() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final sessionJson = preferences.getString(_sessionPreferenceKey);
|
||||||
|
if (sessionJson != null && sessionJson.isNotEmpty) {
|
||||||
|
_session = UserSession.fromJson(
|
||||||
|
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSession(UserSession session) async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_sessionPreferenceKey,
|
||||||
|
jsonEncode(session.toJson()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Berhasil masuk, ${session.fullName}',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.remove(_sessionPreferenceKey);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(context, message: 'Berhasil logout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return AuthPage(onAuthenticated: _setSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfiVoiceShell(session: session, onLogout: _logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPage extends StatefulWidget {
|
||||||
|
const AuthPage({super.key, required this.onAuthenticated});
|
||||||
|
|
||||||
|
final ValueChanged<UserSession> onAuthenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthPageState extends State<AuthPage> {
|
||||||
|
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
|
||||||
|
|
||||||
|
final TextEditingController _apiController = TextEditingController(
|
||||||
|
text: defaultApiEndpoint(),
|
||||||
|
);
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadApiEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_apiController.dispose();
|
||||||
|
_fullNameController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadApiEndpoint() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final endpoint = normalizeApiEndpoint(
|
||||||
|
preferences.getString(_apiEndpointPreferenceKey) ?? '',
|
||||||
|
);
|
||||||
|
if (endpoint.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_apiController.text = endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveApiEndpoint() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_apiEndpointPreferenceKey,
|
||||||
|
_apiController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _authEndpoint(String path) {
|
||||||
|
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
|
||||||
|
if (predictEndpoint == null ||
|
||||||
|
!predictEndpoint.hasScheme ||
|
||||||
|
predictEndpoint.host.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return predictEndpoint.replace(path: path, queryParameters: {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Endpoint API tidak valid.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
final confirmPassword = _confirmPasswordController.text;
|
||||||
|
final fullName = _fullNameController.text.trim();
|
||||||
|
if (username.isEmpty ||
|
||||||
|
password.isEmpty ||
|
||||||
|
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister && password != confirmPassword) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Konfirmasi password belum sama.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (_isRegister) 'full_name': fullName,
|
||||||
|
};
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
endpoint,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final detail =
|
||||||
|
decoded is Map ? decoded['detail']?.toString() : response.body;
|
||||||
|
throw Exception(detail ?? response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveApiEndpoint();
|
||||||
|
if (_isRegister) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final registeredUsername = username;
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_fullNameController.clear();
|
||||||
|
_usernameController.text = registeredUsername;
|
||||||
|
_passwordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Registrasi berhasil. Silakan login.',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final user = UserSession.fromJson(
|
||||||
|
(decoded['user'] as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
widget.onAuthenticated(user);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage =
|
||||||
|
_isRegister
|
||||||
|
? _cleanAuthErrorMessage(error)
|
||||||
|
: 'Username atau password salah.';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanAuthErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
const exceptionPrefix = 'Exception: ';
|
||||||
|
return message.startsWith(exceptionPrefix)
|
||||||
|
? message.substring(exceptionPrefix.length)
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _authFieldDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFF536179)),
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeSwitch() {
|
||||||
|
return Container(
|
||||||
|
height: 46,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFD6E3F7)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment:
|
||||||
|
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 0.5,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF102A56).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Masuk',
|
||||||
|
isSelected: !_isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Daftar',
|
||||||
|
isSelected: _isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 270,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_isRegister
|
||||||
|
? Column(
|
||||||
|
key: const ValueKey('register-form'),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _fullNameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Nama lengkap',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
label: 'Konfirmasi password',
|
||||||
|
icon: Icons.lock_reset_outlined,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
key: const ValueKey('login-form'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.graphic_eq_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 42,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Confivoice',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.20),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildModeSwitch(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_buildAuthForm(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? 'Daftar' : 'Masuk',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AuthErrorBox(message: _errorMessage!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeButton extends StatelessWidget {
|
||||||
|
const _ModeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : const Color(0xFF0F172A),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.decorationBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool obscureText;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final InputDecoration Function({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
})
|
||||||
|
decorationBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: decorationBuilder(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthErrorBox extends StatelessWidget {
|
||||||
|
const _AuthErrorBox({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, dynamic> json) {
|
||||||
|
return UserSession(
|
||||||
|
id:
|
||||||
|
json['id'] is int
|
||||||
|
? json['id'] as int
|
||||||
|
: int.parse(json['id'].toString()),
|
||||||
|
fullName: json['full_name']?.toString() ?? '',
|
||||||
|
username: json['username']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String fullName;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'full_name': fullName, 'username': username};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthGate extends StatefulWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthGate> createState() => _AuthGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthGateState extends State<AuthGate> {
|
||||||
|
static const _sessionPreferenceKey = 'confivoice_user_session';
|
||||||
|
|
||||||
|
UserSession? _session;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSession() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final sessionJson = preferences.getString(_sessionPreferenceKey);
|
||||||
|
if (sessionJson != null && sessionJson.isNotEmpty) {
|
||||||
|
_session = UserSession.fromJson(
|
||||||
|
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSession(UserSession session) async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_sessionPreferenceKey,
|
||||||
|
jsonEncode(session.toJson()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Berhasil masuk, ${session.fullName}',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.remove(_sessionPreferenceKey);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(context, message: 'Berhasil logout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return AuthPage(onAuthenticated: _setSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfiVoiceShell(session: session, onLogout: _logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPage extends StatefulWidget {
|
||||||
|
const AuthPage({super.key, required this.onAuthenticated});
|
||||||
|
|
||||||
|
final ValueChanged<UserSession> onAuthenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthPageState extends State<AuthPage> {
|
||||||
|
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
|
||||||
|
|
||||||
|
final TextEditingController _apiController = TextEditingController(
|
||||||
|
text: defaultApiEndpoint(),
|
||||||
|
);
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadApiEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_apiController.dispose();
|
||||||
|
_fullNameController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadApiEndpoint() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final endpoint = normalizeApiEndpoint(
|
||||||
|
preferences.getString(_apiEndpointPreferenceKey) ?? '',
|
||||||
|
);
|
||||||
|
if (endpoint.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_apiController.text = endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveApiEndpoint() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_apiEndpointPreferenceKey,
|
||||||
|
_apiController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _authEndpoint(String path) {
|
||||||
|
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
|
||||||
|
if (predictEndpoint == null ||
|
||||||
|
!predictEndpoint.hasScheme ||
|
||||||
|
predictEndpoint.host.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return predictEndpoint.replace(path: path, queryParameters: {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Endpoint API tidak valid.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
final confirmPassword = _confirmPasswordController.text;
|
||||||
|
final fullName = _fullNameController.text.trim();
|
||||||
|
if (username.isEmpty ||
|
||||||
|
password.isEmpty ||
|
||||||
|
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister && password != confirmPassword) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Konfirmasi password belum sama.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (_isRegister) 'full_name': fullName,
|
||||||
|
};
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
endpoint,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final detail =
|
||||||
|
decoded is Map ? decoded['detail']?.toString() : response.body;
|
||||||
|
throw Exception(detail ?? response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveApiEndpoint();
|
||||||
|
if (_isRegister) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final registeredUsername = username;
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_fullNameController.clear();
|
||||||
|
_usernameController.text = registeredUsername;
|
||||||
|
_passwordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Registrasi berhasil. Silakan login.',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final user = UserSession.fromJson(
|
||||||
|
(decoded['user'] as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
widget.onAuthenticated(user);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage =
|
||||||
|
_isRegister
|
||||||
|
? _cleanAuthErrorMessage(error)
|
||||||
|
: 'Username atau password salah.';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanAuthErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
const exceptionPrefix = 'Exception: ';
|
||||||
|
return message.startsWith(exceptionPrefix)
|
||||||
|
? message.substring(exceptionPrefix.length)
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _authFieldDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFF536179)),
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeSwitch() {
|
||||||
|
return Container(
|
||||||
|
height: 46,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFD6E3F7)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment:
|
||||||
|
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 0.5,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF102A56).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Masuk',
|
||||||
|
isSelected: !_isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Daftar',
|
||||||
|
isSelected: _isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 270,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_isRegister
|
||||||
|
? Column(
|
||||||
|
key: const ValueKey('register-form'),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _fullNameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Nama lengkap',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
label: 'Konfirmasi password',
|
||||||
|
icon: Icons.lock_reset_outlined,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
key: const ValueKey('login-form'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.graphic_eq_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 42,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Confivoice',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.20),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildModeSwitch(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_buildAuthForm(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? 'Daftar' : 'Masuk',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AuthErrorBox(message: _errorMessage!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeButton extends StatelessWidget {
|
||||||
|
const _ModeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : const Color(0xFF0F172A),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.decorationBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool obscureText;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final InputDecoration Function({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
})
|
||||||
|
decorationBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: decorationBuilder(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthErrorBox extends StatelessWidget {
|
||||||
|
const _AuthErrorBox({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, dynamic> json) {
|
||||||
|
return UserSession(
|
||||||
|
id:
|
||||||
|
json['id'] is int
|
||||||
|
? json['id'] as int
|
||||||
|
: int.parse(json['id'].toString()),
|
||||||
|
fullName: json['full_name']?.toString() ?? '',
|
||||||
|
username: json['username']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String fullName;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'full_name': fullName, 'username': username};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthGate extends StatefulWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthGate> createState() => _AuthGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthGateState extends State<AuthGate> {
|
||||||
|
static const _sessionPreferenceKey = 'confivoice_user_session';
|
||||||
|
|
||||||
|
UserSession? _session;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSession() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final sessionJson = preferences.getString(_sessionPreferenceKey);
|
||||||
|
if (sessionJson != null && sessionJson.isNotEmpty) {
|
||||||
|
_session = UserSession.fromJson(
|
||||||
|
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSession(UserSession session) async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_sessionPreferenceKey,
|
||||||
|
jsonEncode(session.toJson()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Berhasil masuk, ${session.fullName}',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.remove(_sessionPreferenceKey);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(context, message: 'Berhasil logout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return AuthPage(onAuthenticated: _setSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfiVoiceShell(session: session, onLogout: _logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPage extends StatefulWidget {
|
||||||
|
const AuthPage({super.key, required this.onAuthenticated});
|
||||||
|
|
||||||
|
final ValueChanged<UserSession> onAuthenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthPageState extends State<AuthPage> {
|
||||||
|
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
|
||||||
|
|
||||||
|
final TextEditingController _apiController = TextEditingController(
|
||||||
|
text: defaultApiEndpoint(),
|
||||||
|
);
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadApiEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_apiController.dispose();
|
||||||
|
_fullNameController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadApiEndpoint() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final endpoint = normalizeApiEndpoint(
|
||||||
|
preferences.getString(_apiEndpointPreferenceKey) ?? '',
|
||||||
|
);
|
||||||
|
if (endpoint.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_apiController.text = endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveApiEndpoint() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_apiEndpointPreferenceKey,
|
||||||
|
_apiController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _authEndpoint(String path) {
|
||||||
|
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
|
||||||
|
if (predictEndpoint == null ||
|
||||||
|
!predictEndpoint.hasScheme ||
|
||||||
|
predictEndpoint.host.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return predictEndpoint.replace(path: path, queryParameters: {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Endpoint API tidak valid.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
final confirmPassword = _confirmPasswordController.text;
|
||||||
|
final fullName = _fullNameController.text.trim();
|
||||||
|
if (username.isEmpty ||
|
||||||
|
password.isEmpty ||
|
||||||
|
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister && password != confirmPassword) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Konfirmasi password belum sama.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (_isRegister) 'full_name': fullName,
|
||||||
|
};
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
endpoint,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final detail =
|
||||||
|
decoded is Map ? decoded['detail']?.toString() : response.body;
|
||||||
|
throw Exception(detail ?? response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveApiEndpoint();
|
||||||
|
if (_isRegister) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final registeredUsername = username;
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_fullNameController.clear();
|
||||||
|
_usernameController.text = registeredUsername;
|
||||||
|
_passwordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Registrasi berhasil. Silakan login.',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final user = UserSession.fromJson(
|
||||||
|
(decoded['user'] as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
widget.onAuthenticated(user);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage =
|
||||||
|
_isRegister
|
||||||
|
? _cleanAuthErrorMessage(error)
|
||||||
|
: 'Username atau password salah.';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanAuthErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
const exceptionPrefix = 'Exception: ';
|
||||||
|
return message.startsWith(exceptionPrefix)
|
||||||
|
? message.substring(exceptionPrefix.length)
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _authFieldDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFF536179)),
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeSwitch() {
|
||||||
|
return Container(
|
||||||
|
height: 46,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFD6E3F7)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment:
|
||||||
|
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 0.5,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF102A56).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Masuk',
|
||||||
|
isSelected: !_isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Daftar',
|
||||||
|
isSelected: _isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 270,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_isRegister
|
||||||
|
? Column(
|
||||||
|
key: const ValueKey('register-form'),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _fullNameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Nama lengkap',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
label: 'Konfirmasi password',
|
||||||
|
icon: Icons.lock_reset_outlined,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
key: const ValueKey('login-form'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.graphic_eq_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 42,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Confivoice',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.20),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildModeSwitch(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_buildAuthForm(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? 'Daftar' : 'Masuk',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AuthErrorBox(message: _errorMessage!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeButton extends StatelessWidget {
|
||||||
|
const _ModeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : const Color(0xFF0F172A),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.decorationBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool obscureText;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final InputDecoration Function({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
})
|
||||||
|
decorationBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: decorationBuilder(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthErrorBox extends StatelessWidget {
|
||||||
|
const _AuthErrorBox({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, dynamic> json) {
|
||||||
|
return UserSession(
|
||||||
|
id:
|
||||||
|
json['id'] is int
|
||||||
|
? json['id'] as int
|
||||||
|
: int.parse(json['id'].toString()),
|
||||||
|
fullName: json['full_name']?.toString() ?? '',
|
||||||
|
username: json['username']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String fullName;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'full_name': fullName, 'username': username};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthGate extends StatefulWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthGate> createState() => _AuthGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthGateState extends State<AuthGate> {
|
||||||
|
static const _sessionPreferenceKey = 'confivoice_user_session';
|
||||||
|
|
||||||
|
UserSession? _session;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSession() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final sessionJson = preferences.getString(_sessionPreferenceKey);
|
||||||
|
if (sessionJson != null && sessionJson.isNotEmpty) {
|
||||||
|
_session = UserSession.fromJson(
|
||||||
|
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSession(UserSession session) async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_sessionPreferenceKey,
|
||||||
|
jsonEncode(session.toJson()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Berhasil masuk, ${session.fullName}',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.remove(_sessionPreferenceKey);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(context, message: 'Berhasil logout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return AuthPage(onAuthenticated: _setSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfiVoiceShell(session: session, onLogout: _logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPage extends StatefulWidget {
|
||||||
|
const AuthPage({super.key, required this.onAuthenticated});
|
||||||
|
|
||||||
|
final ValueChanged<UserSession> onAuthenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthPageState extends State<AuthPage> {
|
||||||
|
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
|
||||||
|
|
||||||
|
final TextEditingController _apiController = TextEditingController(
|
||||||
|
text: defaultApiEndpoint(),
|
||||||
|
);
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadApiEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_apiController.dispose();
|
||||||
|
_fullNameController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadApiEndpoint() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final endpoint = normalizeApiEndpoint(
|
||||||
|
preferences.getString(_apiEndpointPreferenceKey) ?? '',
|
||||||
|
);
|
||||||
|
if (endpoint.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_apiController.text = endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveApiEndpoint() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_apiEndpointPreferenceKey,
|
||||||
|
_apiController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _authEndpoint(String path) {
|
||||||
|
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
|
||||||
|
if (predictEndpoint == null ||
|
||||||
|
!predictEndpoint.hasScheme ||
|
||||||
|
predictEndpoint.host.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return predictEndpoint.replace(path: path, queryParameters: {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Endpoint API tidak valid.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
final confirmPassword = _confirmPasswordController.text;
|
||||||
|
final fullName = _fullNameController.text.trim();
|
||||||
|
if (username.isEmpty ||
|
||||||
|
password.isEmpty ||
|
||||||
|
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister && password != confirmPassword) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Konfirmasi password belum sama.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (_isRegister) 'full_name': fullName,
|
||||||
|
};
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
endpoint,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final detail =
|
||||||
|
decoded is Map ? decoded['detail']?.toString() : response.body;
|
||||||
|
throw Exception(detail ?? response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveApiEndpoint();
|
||||||
|
if (_isRegister) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final registeredUsername = username;
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_fullNameController.clear();
|
||||||
|
_usernameController.text = registeredUsername;
|
||||||
|
_passwordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Registrasi berhasil. Silakan login.',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final user = UserSession.fromJson(
|
||||||
|
(decoded['user'] as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
widget.onAuthenticated(user);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage =
|
||||||
|
_isRegister
|
||||||
|
? _cleanAuthErrorMessage(error)
|
||||||
|
: 'Username atau password salah.';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanAuthErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
const exceptionPrefix = 'Exception: ';
|
||||||
|
return message.startsWith(exceptionPrefix)
|
||||||
|
? message.substring(exceptionPrefix.length)
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _authFieldDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFF536179)),
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeSwitch() {
|
||||||
|
return Container(
|
||||||
|
height: 46,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFD6E3F7)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment:
|
||||||
|
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 0.5,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF102A56).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Masuk',
|
||||||
|
isSelected: !_isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Daftar',
|
||||||
|
isSelected: _isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 270,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_isRegister
|
||||||
|
? Column(
|
||||||
|
key: const ValueKey('register-form'),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _fullNameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Nama lengkap',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
label: 'Konfirmasi password',
|
||||||
|
icon: Icons.lock_reset_outlined,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
key: const ValueKey('login-form'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.graphic_eq_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 42,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Confivoice',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.20),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildModeSwitch(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_buildAuthForm(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? 'Daftar' : 'Masuk',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AuthErrorBox(message: _errorMessage!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeButton extends StatelessWidget {
|
||||||
|
const _ModeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : const Color(0xFF0F172A),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.decorationBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool obscureText;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final InputDecoration Function({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
})
|
||||||
|
decorationBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: decorationBuilder(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthErrorBox extends StatelessWidget {
|
||||||
|
const _AuthErrorBox({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a login form with email and password validation
|
||||||
|
|
@ -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<String, dynamic> json) {
|
||||||
|
return UserSession(
|
||||||
|
id:
|
||||||
|
json['id'] is int
|
||||||
|
? json['id'] as int
|
||||||
|
: int.parse(json['id'].toString()),
|
||||||
|
fullName: json['full_name']?.toString() ?? '',
|
||||||
|
username: json['username']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String fullName;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'full_name': fullName, 'username': username};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthGate extends StatefulWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthGate> createState() => _AuthGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthGateState extends State<AuthGate> {
|
||||||
|
static const _sessionPreferenceKey = 'confivoice_user_session';
|
||||||
|
|
||||||
|
UserSession? _session;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSession() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final sessionJson = preferences.getString(_sessionPreferenceKey);
|
||||||
|
if (sessionJson != null && sessionJson.isNotEmpty) {
|
||||||
|
_session = UserSession.fromJson(
|
||||||
|
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSession(UserSession session) async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_sessionPreferenceKey,
|
||||||
|
jsonEncode(session.toJson()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Berhasil masuk, ${session.fullName}',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.remove(_sessionPreferenceKey);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(context, message: 'Berhasil logout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return AuthPage(onAuthenticated: _setSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfiVoiceShell(session: session, onLogout: _logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPage extends StatefulWidget {
|
||||||
|
const AuthPage({super.key, required this.onAuthenticated});
|
||||||
|
|
||||||
|
final ValueChanged<UserSession> onAuthenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthPageState extends State<AuthPage> {
|
||||||
|
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
|
||||||
|
|
||||||
|
final TextEditingController _apiController = TextEditingController(
|
||||||
|
text: defaultApiEndpoint(),
|
||||||
|
);
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadApiEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_apiController.dispose();
|
||||||
|
_fullNameController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadApiEndpoint() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final endpoint = normalizeApiEndpoint(
|
||||||
|
preferences.getString(_apiEndpointPreferenceKey) ?? '',
|
||||||
|
);
|
||||||
|
if (endpoint.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_apiController.text = endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveApiEndpoint() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_apiEndpointPreferenceKey,
|
||||||
|
_apiController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _authEndpoint(String path) {
|
||||||
|
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
|
||||||
|
if (predictEndpoint == null ||
|
||||||
|
!predictEndpoint.hasScheme ||
|
||||||
|
predictEndpoint.host.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return predictEndpoint.replace(path: path, queryParameters: {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Endpoint API tidak valid.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
final confirmPassword = _confirmPasswordController.text;
|
||||||
|
final fullName = _fullNameController.text.trim();
|
||||||
|
if (username.isEmpty ||
|
||||||
|
password.isEmpty ||
|
||||||
|
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister && password != confirmPassword) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Konfirmasi password belum sama.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (_isRegister) 'full_name': fullName,
|
||||||
|
};
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
endpoint,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final detail =
|
||||||
|
decoded is Map ? decoded['detail']?.toString() : response.body;
|
||||||
|
throw Exception(detail ?? response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveApiEndpoint();
|
||||||
|
if (_isRegister) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final registeredUsername = username;
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_fullNameController.clear();
|
||||||
|
_usernameController.text = registeredUsername;
|
||||||
|
_passwordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Registrasi berhasil. Silakan login.',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final user = UserSession.fromJson(
|
||||||
|
(decoded['user'] as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
widget.onAuthenticated(user);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage =
|
||||||
|
_isRegister
|
||||||
|
? _cleanAuthErrorMessage(error)
|
||||||
|
: 'Username atau password salah.';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanAuthErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
const exceptionPrefix = 'Exception: ';
|
||||||
|
return message.startsWith(exceptionPrefix)
|
||||||
|
? message.substring(exceptionPrefix.length)
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _authFieldDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFF536179)),
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeSwitch() {
|
||||||
|
return Container(
|
||||||
|
height: 46,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFD6E3F7)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment:
|
||||||
|
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 0.5,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF102A56).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Masuk',
|
||||||
|
isSelected: !_isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Daftar',
|
||||||
|
isSelected: _isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 270,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_isRegister
|
||||||
|
? Column(
|
||||||
|
key: const ValueKey('register-form'),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _fullNameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Nama lengkap',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
label: 'Konfirmasi password',
|
||||||
|
icon: Icons.lock_reset_outlined,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
key: const ValueKey('login-form'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.graphic_eq_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 42,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Confivoice',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.20),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildModeSwitch(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_buildAuthForm(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? 'Daftar' : 'Masuk',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AuthErrorBox(message: _errorMessage!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeButton extends StatelessWidget {
|
||||||
|
const _ModeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : const Color(0xFF0F172A),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.decorationBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool obscureText;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final InputDecoration Function({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
})
|
||||||
|
decorationBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: decorationBuilder(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthErrorBox extends StatelessWidget {
|
||||||
|
const _AuthErrorBox({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a login form with email and password validation
|
||||||
|
|
@ -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<String, dynamic> json) {
|
||||||
|
return UserSession(
|
||||||
|
id:
|
||||||
|
json['id'] is int
|
||||||
|
? json['id'] as int
|
||||||
|
: int.parse(json['id'].toString()),
|
||||||
|
fullName: json['full_name']?.toString() ?? '',
|
||||||
|
username: json['username']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String fullName;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'full_name': fullName, 'username': username};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthGate extends StatefulWidget {
|
||||||
|
const AuthGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthGate> createState() => _AuthGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthGateState extends State<AuthGate> {
|
||||||
|
static const _sessionPreferenceKey = 'confivoice_user_session';
|
||||||
|
|
||||||
|
UserSession? _session;
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSession() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final sessionJson = preferences.getString(_sessionPreferenceKey);
|
||||||
|
if (sessionJson != null && sessionJson.isNotEmpty) {
|
||||||
|
_session = UserSession.fromJson(
|
||||||
|
(jsonDecode(sessionJson) as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSession(UserSession session) async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_sessionPreferenceKey,
|
||||||
|
jsonEncode(session.toJson()),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = session;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Berhasil masuk, ${session.fullName}',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.remove(_sessionPreferenceKey);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_session = null;
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
showCompactSnackBar(context, message: 'Berhasil logout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
final session = _session;
|
||||||
|
if (session == null) {
|
||||||
|
return AuthPage(onAuthenticated: _setSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfiVoiceShell(session: session, onLogout: _logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthPage extends StatefulWidget {
|
||||||
|
const AuthPage({super.key, required this.onAuthenticated});
|
||||||
|
|
||||||
|
final ValueChanged<UserSession> onAuthenticated;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthPageState extends State<AuthPage> {
|
||||||
|
static const _apiEndpointPreferenceKey = 'confivoice_api_endpoint';
|
||||||
|
|
||||||
|
final TextEditingController _apiController = TextEditingController(
|
||||||
|
text: defaultApiEndpoint(),
|
||||||
|
);
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
bool _isRegister = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadApiEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_apiController.dispose();
|
||||||
|
_fullNameController.dispose();
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadApiEndpoint() async {
|
||||||
|
try {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
final endpoint = normalizeApiEndpoint(
|
||||||
|
preferences.getString(_apiEndpointPreferenceKey) ?? '',
|
||||||
|
);
|
||||||
|
if (endpoint.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_apiController.text = endpoint;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveApiEndpoint() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
await preferences.setString(
|
||||||
|
_apiEndpointPreferenceKey,
|
||||||
|
_apiController.text.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _authEndpoint(String path) {
|
||||||
|
final predictEndpoint = Uri.tryParse(_apiController.text.trim());
|
||||||
|
if (predictEndpoint == null ||
|
||||||
|
!predictEndpoint.hasScheme ||
|
||||||
|
predictEndpoint.host.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return predictEndpoint.replace(path: path, queryParameters: {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final endpoint = _authEndpoint(_isRegister ? '/register' : '/login');
|
||||||
|
if (endpoint == null) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Endpoint API tidak valid.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final username = _usernameController.text.trim();
|
||||||
|
final password = _passwordController.text;
|
||||||
|
final confirmPassword = _confirmPasswordController.text;
|
||||||
|
final fullName = _fullNameController.text.trim();
|
||||||
|
if (username.isEmpty ||
|
||||||
|
password.isEmpty ||
|
||||||
|
(_isRegister && (fullName.isEmpty || confirmPassword.isEmpty))) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Lengkapi data akun terlebih dahulu.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRegister && password != confirmPassword) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Konfirmasi password belum sama.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
if (_isRegister) 'full_name': fullName,
|
||||||
|
};
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
endpoint,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final detail =
|
||||||
|
decoded is Map ? decoded['detail']?.toString() : response.body;
|
||||||
|
throw Exception(detail ?? response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveApiEndpoint();
|
||||||
|
if (_isRegister) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final registeredUsername = username;
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_fullNameController.clear();
|
||||||
|
_usernameController.text = registeredUsername;
|
||||||
|
_passwordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
showCompactSnackBar(
|
||||||
|
context,
|
||||||
|
message: 'Registrasi berhasil. Silakan login.',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final user = UserSession.fromJson(
|
||||||
|
(decoded['user'] as Map).cast<String, dynamic>(),
|
||||||
|
);
|
||||||
|
widget.onAuthenticated(user);
|
||||||
|
} catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage =
|
||||||
|
_isRegister
|
||||||
|
? _cleanAuthErrorMessage(error)
|
||||||
|
: 'Username atau password salah.';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cleanAuthErrorMessage(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
const exceptionPrefix = 'Exception: ';
|
||||||
|
return message.startsWith(exceptionPrefix)
|
||||||
|
? message.substring(exceptionPrefix.length)
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _authFieldDecoration({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
|
return InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFF536179)),
|
||||||
|
prefixIcon: Icon(icon, color: AppColors.navy, size: 20),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFD8DFEA)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.blue, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.recording),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildModeSwitch() {
|
||||||
|
return Container(
|
||||||
|
height: 46,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFD6E3F7)),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment:
|
||||||
|
_isRegister ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
widthFactor: 0.5,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0D2A52), Color(0xFF2563EB)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF102A56).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Masuk',
|
||||||
|
isSelected: !_isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ModeButton(
|
||||||
|
label: 'Daftar',
|
||||||
|
isSelected: _isRegister,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isRegister = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 270,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: Offset(_isRegister ? 0.10 : -0.10, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
_isRegister
|
||||||
|
? Column(
|
||||||
|
key: const ValueKey('register-form'),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _fullNameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Nama lengkap',
|
||||||
|
icon: Icons.badge_outlined,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _confirmPasswordController,
|
||||||
|
obscureText: _obscureConfirmPassword,
|
||||||
|
label: 'Konfirmasi password',
|
||||||
|
icon: Icons.lock_reset_outlined,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
key: const ValueKey('login-form'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _usernameController,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: _authFieldDecoration(
|
||||||
|
label: 'Username',
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_PasswordField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
label: 'Password',
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
decorationBuilder: _authFieldDecoration,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF081A33), Color(0xFF0F3A6D), Color(0xFF5EB2F7)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.graphic_eq_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 42,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'Confivoice',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.20),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildModeSwitch(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_buildAuthForm(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.navy,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_isRegister ? 'Daftar' : 'Masuk',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AuthErrorBox(message: _errorMessage!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModeButton extends StatelessWidget {
|
||||||
|
const _ModeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.white : const Color(0xFF0F172A),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordField extends StatelessWidget {
|
||||||
|
const _PasswordField({
|
||||||
|
required this.controller,
|
||||||
|
required this.obscureText,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onSubmitted,
|
||||||
|
required this.decorationBuilder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool obscureText;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final ValueChanged<String> onSubmitted;
|
||||||
|
final InputDecoration Function({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
})
|
||||||
|
decorationBuilder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscureText,
|
||||||
|
style: const TextStyle(color: Color(0xFF0F172A)),
|
||||||
|
decoration: decorationBuilder(
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: obscureText ? 'Tampilkan password' : 'Sembunyikan password',
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
obscureText
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: AppColors.navy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: onSubmitted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthErrorBox extends StatelessWidget {
|
||||||
|
const _AuthErrorBox({required this.message});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: const TextStyle(color: Color(0xFF92400E), fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> studentNames;
|
||||||
|
final String studentGender;
|
||||||
|
final bool hasStoredStudentGender;
|
||||||
|
final SelectedAudio? selectedAudio;
|
||||||
|
final List<double> liveWavePreview;
|
||||||
|
final List<double> wavePreview;
|
||||||
|
final PredictionResult? prediction;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool isPredicting;
|
||||||
|
final bool isSaving;
|
||||||
|
final bool isRecording;
|
||||||
|
final Future<void> Function(String name) onAddStudentName;
|
||||||
|
final ValueChanged<String> onSelectStudentName;
|
||||||
|
final ValueChanged<String?> 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<String> names;
|
||||||
|
final Future<void> Function(String name) onAddName;
|
||||||
|
final ValueChanged<String> 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<String?> 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<String>(
|
||||||
|
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<String> 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<void> _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<double> liveWavePreview;
|
||||||
|
final List<double> 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<double> values;
|
||||||
|
final bool isLive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WaveformPreview> createState() => _WaveformPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaveformPreviewState extends State<WaveformPreview>
|
||||||
|
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<double> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> studentNames;
|
||||||
|
final String studentGender;
|
||||||
|
final bool hasStoredStudentGender;
|
||||||
|
final SelectedAudio? selectedAudio;
|
||||||
|
final List<double> liveWavePreview;
|
||||||
|
final List<double> wavePreview;
|
||||||
|
final PredictionResult? prediction;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool isPredicting;
|
||||||
|
final bool isSaving;
|
||||||
|
final bool isRecording;
|
||||||
|
final Future<void> Function(String name) onAddStudentName;
|
||||||
|
final ValueChanged<String> onSelectStudentName;
|
||||||
|
final ValueChanged<String?> 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<String> names;
|
||||||
|
final Future<void> Function(String name) onAddName;
|
||||||
|
final ValueChanged<String> 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<String?> 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<String>(
|
||||||
|
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<String> 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<void> _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<double> liveWavePreview;
|
||||||
|
final List<double> 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<double> values;
|
||||||
|
final bool isLive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WaveformPreview> createState() => _WaveformPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaveformPreviewState extends State<WaveformPreview>
|
||||||
|
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<double> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> studentNames;
|
||||||
|
final String studentGender;
|
||||||
|
final bool hasStoredStudentGender;
|
||||||
|
final SelectedAudio? selectedAudio;
|
||||||
|
final List<double> liveWavePreview;
|
||||||
|
final List<double> wavePreview;
|
||||||
|
final PredictionResult? prediction;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool isPredicting;
|
||||||
|
final bool isSaving;
|
||||||
|
final bool isRecording;
|
||||||
|
final Future<void> Function(String name) onAddStudentName;
|
||||||
|
final ValueChanged<String> onSelectStudentName;
|
||||||
|
final ValueChanged<String?> 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<String> names;
|
||||||
|
final Future<void> Function(String name) onAddName;
|
||||||
|
final ValueChanged<String> 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<String?> 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<String>(
|
||||||
|
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<String> 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<void> _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<double> liveWavePreview;
|
||||||
|
final List<double> 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<double> values;
|
||||||
|
final bool isLive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WaveformPreview> createState() => _WaveformPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaveformPreviewState extends State<WaveformPreview>
|
||||||
|
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<double> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> studentNames;
|
||||||
|
final String studentGender;
|
||||||
|
final bool hasStoredStudentGender;
|
||||||
|
final SelectedAudio? selectedAudio;
|
||||||
|
final List<double> liveWavePreview;
|
||||||
|
final List<double> wavePreview;
|
||||||
|
final PredictionResult? prediction;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool isPredicting;
|
||||||
|
final bool isSaving;
|
||||||
|
final bool isRecording;
|
||||||
|
final Future<void> Function(String name) onAddStudentName;
|
||||||
|
final ValueChanged<String> onSelectStudentName;
|
||||||
|
final ValueChanged<String?> 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<String> names;
|
||||||
|
final Future<void> Function(String name) onAddName;
|
||||||
|
final ValueChanged<String> 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<String?> 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<String>(
|
||||||
|
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<String> 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<void> _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<double> liveWavePreview;
|
||||||
|
final List<double> 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<double> values;
|
||||||
|
final bool isLive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WaveformPreview> createState() => _WaveformPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaveformPreviewState extends State<WaveformPreview>
|
||||||
|
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<double> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> studentNames;
|
||||||
|
final String studentGender;
|
||||||
|
final bool hasStoredStudentGender;
|
||||||
|
final SelectedAudio? selectedAudio;
|
||||||
|
final List<double> liveWavePreview;
|
||||||
|
final List<double> wavePreview;
|
||||||
|
final PredictionResult? prediction;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool isPredicting;
|
||||||
|
final bool isSaving;
|
||||||
|
final bool isRecording;
|
||||||
|
final Future<void> Function(String name) onAddStudentName;
|
||||||
|
final ValueChanged<String> onSelectStudentName;
|
||||||
|
final ValueChanged<String?> 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<String> names;
|
||||||
|
final Future<void> Function(String name) onAddName;
|
||||||
|
final ValueChanged<String> 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<String?> 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<String>(
|
||||||
|
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<String> 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<void> _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<double> liveWavePreview;
|
||||||
|
final List<double> 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<double> values;
|
||||||
|
final bool isLive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WaveformPreview> createState() => _WaveformPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaveformPreviewState extends State<WaveformPreview>
|
||||||
|
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<double> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> studentNames;
|
||||||
|
final String studentGender;
|
||||||
|
final bool hasStoredStudentGender;
|
||||||
|
final SelectedAudio? selectedAudio;
|
||||||
|
final List<double> liveWavePreview;
|
||||||
|
final List<double> wavePreview;
|
||||||
|
final PredictionResult? prediction;
|
||||||
|
final String? errorMessage;
|
||||||
|
final bool isPredicting;
|
||||||
|
final bool isSaving;
|
||||||
|
final bool isRecording;
|
||||||
|
final Future<void> Function(String name) onAddStudentName;
|
||||||
|
final ValueChanged<String> onSelectStudentName;
|
||||||
|
final ValueChanged<String?> 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<String> names;
|
||||||
|
final Future<void> Function(String name) onAddName;
|
||||||
|
final ValueChanged<String> 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<String?> 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<String>(
|
||||||
|
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<String> 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<void> _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<double> liveWavePreview;
|
||||||
|
final List<double> 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<double> values;
|
||||||
|
final bool isLive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WaveformPreview> createState() => _WaveformPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WaveformPreviewState extends State<WaveformPreview>
|
||||||
|
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<double> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak percaya diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata PD</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak percaya diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata PercayaD</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak percaya diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak ercaya diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya Diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total Data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya Diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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="""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total Data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya Diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total Data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya Diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total Data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya Diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total Data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya Diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesa error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
@ -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"""
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Edit Data #{prediction_id}</h2>
|
||||||
|
<p>Ubah hasil analisis yang tersimpan di admin.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button secondary" href="/admin">Kembali</a>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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"""
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Admin ConfiVoice</h1>
|
||||||
|
<p>Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="stats">
|
||||||
|
<div class="stat"><span>Total Data</span><strong>{total}</strong></div>
|
||||||
|
<div class="stat"><span>Percaya Diri</span><strong>{pd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Tidak Percaya Diri</span><strong>{tpd_count}</strong></div>
|
||||||
|
<div class="stat"><span>Rata-rata Percaya Diri</span><strong>{avg_pd * 100:.1f}%</strong></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Tambah Data</h2>
|
||||||
|
<p>Gunakan form ini jika perlu menambahkan hasil analisis secara manual.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div>
|
||||||
|
<h2>Data Analisis</h2>
|
||||||
|
<p>Kelola data yang sudah tersimpan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{_prediction_table(rows)}
|
||||||
|
</section>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<form class="form-grid" method="post" action="{escape(action)}">
|
||||||
|
<label>
|
||||||
|
<span>Nama siswa/i</span>
|
||||||
|
<input name="student_name" value="{student_name}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<select name="predicted_label">
|
||||||
|
<option value="PD" {pd_selected}>PD</option>
|
||||||
|
<option value="TPD" {tpd_selected}>TPD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Confidence (%)</span>
|
||||||
|
<input name="confidence_percent" type="number" min="0" max="100" step="0.01" value="{confidence}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>PD (%)</span>
|
||||||
|
<input name="probability_pd_percent" type="number" min="0" max="100" step="0.01" value="{probability_pd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>TPD (%)</span>
|
||||||
|
<input name="probability_tpd_percent" type="number" min="0" max="100" step="0.01" value="{probability_tpd}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Durasi audio (detik)</span>
|
||||||
|
<input name="audio_duration" type="number" min="0" step="0.01" value="{audio_duration:.2f}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status audio</span>
|
||||||
|
<select name="is_valid_audio">
|
||||||
|
<option value="1" {valid_selected}>Valid</option>
|
||||||
|
<option value="0" {invalid_selected}>Tidak valid</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Keterangan</span>
|
||||||
|
<input name="description" value="{description}">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Pesan error audio</span>
|
||||||
|
<input name="error_message" value="{error_message}">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" type="submit">{escape(submit_label)}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_table(rows):
|
||||||
|
table_rows = []
|
||||||
|
for row in rows:
|
||||||
|
valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid"
|
||||||
|
table_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{row['id']}</td>"
|
||||||
|
f"<td>{escape(str(row['created_at']))}</td>"
|
||||||
|
f"<td>{escape(row['student_name'])}</td>"
|
||||||
|
f"<td><span class='badge'>{escape(row['predicted_label'] or '-')}</span></td>"
|
||||||
|
f"<td>{escape(row['description'] or '-')}</td>"
|
||||||
|
f"<td>{float(row['confidence'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_pd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['probability_tpd'] or 0) * 100:.2f}%</td>"
|
||||||
|
f"<td>{float(row['audio_duration'] or 0):.2f} dtk</td>"
|
||||||
|
f"<td>{valid_text}</td>"
|
||||||
|
"<td class='row-actions'>"
|
||||||
|
f"<a class='small-button' href='/admin/predictions/{row['id']}/edit'>Edit</a>"
|
||||||
|
f"<form method='post' action='/admin/predictions/{row['id']}/delete' "
|
||||||
|
"onsubmit=\"return confirm('Hapus data ini?');\">"
|
||||||
|
"<button class='small-button danger' type='submit'>Hapus</button>"
|
||||||
|
"</form>"
|
||||||
|
"</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = "\n".join(table_rows) or (
|
||||||
|
'<tr><td colspan="11" class="empty">Belum ada hasil prediksi.</td></tr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
<th>Nama siswa/i</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Keterangan</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>PD</th>
|
||||||
|
<th>TPD</th>
|
||||||
|
<th>Durasi</th>
|
||||||
|
<th>Status audio</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_page(title, content):
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="id">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{escape(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f6f8f8;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}}
|
||||||
|
h1, h2 {{
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 28px;
|
||||||
|
}}
|
||||||
|
h2 {{
|
||||||
|
font-size: 20px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0;
|
||||||
|
color: #586966;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
.stat, .panel {{
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dde7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
.stat {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stat span {{
|
||||||
|
display: block;
|
||||||
|
color: #687976;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
.stat strong {{
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
.panel {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}}
|
||||||
|
label span {{
|
||||||
|
display: block;
|
||||||
|
color: #586966;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}}
|
||||||
|
input, select {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #cbd9d6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211f;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
.form-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}}
|
||||||
|
.button, .small-button {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}}
|
||||||
|
.button:hover, .small-button:hover {{
|
||||||
|
background: #115e59;
|
||||||
|
}}
|
||||||
|
.button.secondary, .small-button {{
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
}}
|
||||||
|
.button.secondary:hover, .small-button:hover {{
|
||||||
|
background: #d3ebe7;
|
||||||
|
}}
|
||||||
|
.small-button {{
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}}
|
||||||
|
.small-button.danger {{
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}}
|
||||||
|
.small-button.danger:hover {{
|
||||||
|
background: #fecaca;
|
||||||
|
}}
|
||||||
|
.table-wrap {{
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}}
|
||||||
|
table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 1240px;
|
||||||
|
}}
|
||||||
|
th, td {{
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #e8efed;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}}
|
||||||
|
th {{
|
||||||
|
background: #eef6f4;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}}
|
||||||
|
tr:last-child td {{
|
||||||
|
border-bottom: 0;
|
||||||
|
}}
|
||||||
|
.badge {{
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f4f1;
|
||||||
|
color: #0f766e;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.empty {{
|
||||||
|
text-align: center;
|
||||||
|
color: #687a76;
|
||||||
|
}}
|
||||||
|
.row-actions {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}}
|
||||||
|
.row-actions form {{
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
@media (max-width: 980px) {{
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 2;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
@media (max-width: 760px) {{
|
||||||
|
body {{
|
||||||
|
padding: 14px;
|
||||||
|
}}
|
||||||
|
.stats {{
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}}
|
||||||
|
.form-grid {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
.wide {{
|
||||||
|
grid-column: span 1;
|
||||||
|
}}
|
||||||
|
.panel-head {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.panel-head .button {{
|
||||||
|
margin-top: 12px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{content}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue