Commit 12872cd6 authored by gustixjerk's avatar gustixjerk

feat(mobile): implement PRD features (forgot password OTP, full article detail, and edit profile)

parent 56506d32
This diff is collapsed.
...@@ -132,6 +132,56 @@ class ApiService { ...@@ -132,6 +132,56 @@ class ApiService {
} }
} }
static Future<ApiResponse<Map<String, dynamic>>> forgotPassword(String identifier) async {
try {
final url = Uri.parse('$baseUrl/auth/forgot-password');
final res = await http.post(
url,
headers: _headers(withAuth: false),
body: jsonEncode({'identifier': identifier}),
);
final data = jsonDecode(utf8.decode(res.bodyBytes));
if (res.statusCode == 200) {
return ApiResponse(success: true, data: data, statusCode: res.statusCode);
} else {
final msg = data['detail'] ?? data['message'] ?? 'Gagal memproses lupa password.';
return ApiResponse(success: false, message: msg.toString(), statusCode: res.statusCode);
}
} catch (e) {
return ApiResponse(success: false, message: 'Koneksi error: $e', statusCode: 0);
}
}
static Future<ApiResponse<Map<String, dynamic>>> resetPassword({
required String phone,
required String otp,
required String password,
}) async {
try {
final url = Uri.parse('$baseUrl/auth/reset-password');
final res = await http.post(
url,
headers: _headers(withAuth: false),
body: jsonEncode({
'phone': phone,
'otp': otp,
'password': password,
}),
);
final data = jsonDecode(utf8.decode(res.bodyBytes));
if (res.statusCode == 200) {
return ApiResponse(success: true, data: data, statusCode: res.statusCode);
} else {
final msg = data['detail'] ?? data['message'] ?? 'Reset password gagal.';
return ApiResponse(success: false, message: msg.toString(), statusCode: res.statusCode);
}
} catch (e) {
return ApiResponse(success: false, message: 'Koneksi error: $e', statusCode: 0);
}
}
// Me / Profile Endpoint (no trailing slash) // Me / Profile Endpoint (no trailing slash)
static Future<ApiResponse<Map<String, dynamic>>> getMe() async { static Future<ApiResponse<Map<String, dynamic>>> getMe() async {
try { try {
...@@ -148,6 +198,30 @@ class ApiService { ...@@ -148,6 +198,30 @@ class ApiService {
} }
} }
static Future<ApiResponse<Map<String, dynamic>>> updateProfile(Map<String, dynamic> body) async {
try {
final url = Uri.parse('$baseUrl/me');
final res = await http.patch(
url,
headers: _headers(),
body: jsonEncode(body),
);
final data = jsonDecode(utf8.decode(res.bodyBytes));
if (res.statusCode == 200) {
return ApiResponse(
success: true,
data: data is Map<String, dynamic> ? data : null,
statusCode: res.statusCode,
);
} else {
final msg = data['detail'] ?? data['message'] ?? 'Gagal memperbarui profil.';
return ApiResponse(success: false, message: msg.toString(), statusCode: res.statusCode);
}
} catch (e) {
return ApiResponse(success: false, message: 'Koneksi error: $e', statusCode: 0);
}
}
// Pengcab List (trailing slash) // Pengcab List (trailing slash)
static Future<ApiResponse<List<dynamic>>> getPengcabs() async { static Future<ApiResponse<List<dynamic>>> getPengcabs() async {
try { try {
......
This diff is collapsed.
...@@ -5,6 +5,7 @@ import '../../core/services/storage_service.dart'; ...@@ -5,6 +5,7 @@ import '../../core/services/storage_service.dart';
import '../../providers/app_state.dart'; import '../../providers/app_state.dart';
import '../main_navigation_screen.dart'; import '../main_navigation_screen.dart';
import 'register_screen.dart'; import 'register_screen.dart';
import 'forgot_password_screen.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
...@@ -106,9 +107,9 @@ class _LoginScreenState extends State<LoginScreen> { ...@@ -106,9 +107,9 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( const Text(
'Default Server: ${AppConstants.productionBaseUrl}\nLokal Server: http://127.0.0.1:8000/api/v1', 'Default Server: ${AppConstants.productionBaseUrl}\nLokal Server: http://127.0.0.1:8000/api/v1',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted), style: TextStyle(fontSize: 11, color: AppColors.textMuted),
), ),
], ],
), ),
...@@ -119,13 +120,14 @@ class _LoginScreenState extends State<LoginScreen> { ...@@ -119,13 +120,14 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
await StorageService.setApiBaseUrl(controller.text.trim()); final val = controller.text.trim();
if (ctx.mounted) Navigator.pop(ctx); final messenger = ScaffoldMessenger.of(context);
if (mounted) { final nav = Navigator.of(ctx);
ScaffoldMessenger.of(context).showSnackBar( await StorageService.setApiBaseUrl(val);
SnackBar(content: Text('Server diset ke: ${controller.text.trim()}')), nav.pop();
); messenger.showSnackBar(
} SnackBar(content: Text('Server diset ke: $val')),
);
}, },
style: ElevatedButton.styleFrom(backgroundColor: AppColors.primary), style: ElevatedButton.styleFrom(backgroundColor: AppColors.primary),
child: const Text('Simpan'), child: const Text('Simpan'),
...@@ -285,8 +287,9 @@ class _LoginScreenState extends State<LoginScreen> { ...@@ -285,8 +287,9 @@ class _LoginScreenState extends State<LoginScreen> {
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
ScaffoldMessenger.of(context).showSnackBar( Navigator.push(
const SnackBar(content: Text('Fitur reset password via OTP dapat diakses melalui admin/bantuan.')), context,
MaterialPageRoute(builder: (_) => const ForgotPasswordScreen()),
); );
}, },
child: const Text( child: const Text(
......
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
import '../../models/content_model.dart';
class ArticleDetailScreen extends StatelessWidget {
final ArticleModel article;
const ArticleDetailScreen({super.key, required this.article});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Kabar & Berita Resmi'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Badge & Author Info
Row(
children: [
if (article.isOfficial)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.circuitGoldContainer,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.circuitGold.withOpacity(0.4)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.verified, size: 14, color: AppColors.circuitGold),
SizedBox(width: 4),
Text(
'RILIS RESMI IMI JABAR',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
color: AppColors.circuitGold,
letterSpacing: 0.5,
),
),
],
),
),
const Spacer(),
const Icon(Icons.schedule, size: 14, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
article.publishedAt != null && article.publishedAt!.length >= 10
? article.publishedAt!.substring(0, 10)
: 'Terbaru',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
),
const SizedBox(height: 14),
// Title
Text(
article.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900,
height: 1.25,
),
),
const SizedBox(height: 10),
// Byline
Row(
children: [
const CircleAvatar(
radius: 12,
backgroundColor: AppColors.primary,
child: Icon(Icons.person, size: 14, color: Colors.white),
),
const SizedBox(width: 8),
Text(
article.authorByline ?? 'Divisi Humas IMI Jabar',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textSecondary),
),
],
),
const SizedBox(height: 16),
// Cover Image (if available) or Graphic Banner
Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: AppColors.surfaceCard,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderSubtle),
),
clipBehavior: Clip.antiAlias,
child: article.coverImage != null && article.coverImage!.isNotEmpty
? Image.network(
article.coverImage!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPlaceholderGraphic(),
)
: _buildPlaceholderGraphic(),
),
const SizedBox(height: 20),
// Excerpt Callout
if (article.excerpt != null && article.excerpt!.isNotEmpty)
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(10),
border: const Border(
left: BorderSide(color: AppColors.primary, width: 4),
),
),
child: Text(
article.excerpt!,
style: const TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
color: AppColors.textSecondary,
height: 1.45,
),
),
),
const SizedBox(height: 20),
// Body Content
Text(
article.body != null && article.body!.isNotEmpty
? article.body!
: (article.excerpt ?? 'Informasi resmi dari Ikatan Motor Indonesia Pengprov Jawa Barat.'),
style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
height: 1.7,
),
),
const SizedBox(height: 30),
// Footer / Share
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceCard,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderSubtle),
),
child: Row(
children: [
const Icon(Icons.share, color: AppColors.primary, size: 20),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Bagikan pengumuman resmi ini ke komunitas atau klub balap Anda.',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tautan rilis resmi disalin')),
);
},
child: const Text('Salin Link'),
),
],
),
),
const SizedBox(height: 20),
],
),
),
);
}
Widget _buildPlaceholderGraphic() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1E293B), Color(0xFF0F172A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
right: -30,
bottom: -30,
child: Icon(
Icons.sports_motorsports,
size: 160,
color: Colors.white.withOpacity(0.04),
),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo_imi.png',
height: 52,
errorBuilder: (_, __, ___) => const Icon(Icons.newspaper, size: 48, color: AppColors.primary),
),
const SizedBox(height: 10),
const Text(
'WARTA MOTORSPORT JAWA BARAT',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
color: AppColors.textMuted,
),
),
],
),
],
),
);
}
}
This diff is collapsed.
This diff is collapsed.
...@@ -4,6 +4,7 @@ import '../../core/theme/app_theme.dart'; ...@@ -4,6 +4,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/services/api_service.dart'; import '../../core/services/api_service.dart';
import '../../core/services/storage_service.dart'; import '../../core/services/storage_service.dart';
import '../../models/user_model.dart'; import '../../models/user_model.dart';
import 'edit_profile_screen.dart';
import '../kta/kta_digital_screen.dart'; import '../kta/kta_digital_screen.dart';
import '../ticketing/my_tickets_screen.dart'; import '../ticketing/my_tickets_screen.dart';
import '../splash/welcome_screen.dart'; import '../splash/welcome_screen.dart';
...@@ -219,6 +220,25 @@ class _ProfileScreenState extends State<ProfileScreen> { ...@@ -219,6 +220,25 @@ class _ProfileScreenState extends State<ProfileScreen> {
), ),
child: Column( child: Column(
children: [ children: [
if (_user != null) ...[
_buildMenuItem(
icon: Icons.edit_note,
title: 'Ubah Data Profil',
subtitle: 'Perbarui nama, klub otomotif, atau alamat domisili',
onTap: () async {
final updated = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => EditProfileScreen(user: _user!),
),
);
if (updated == true) {
_loadProfile();
}
},
),
const Divider(color: AppColors.borderSubtle, height: 1),
],
_buildMenuItem( _buildMenuItem(
icon: Icons.credit_card, icon: Icons.credit_card,
title: 'KTA Digital & QR Pass', title: 'KTA Digital & QR Pass',
......
...@@ -170,14 +170,14 @@ class _PaymentInstructionScreenState extends State<PaymentInstructionScreen> { ...@@ -170,14 +170,14 @@ class _PaymentInstructionScreenState extends State<PaymentInstructionScreen> {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.borderSubtle), border: Border.all(color: AppColors.borderSubtle),
), ),
child: Row( child: const Row(
children: [ children: [
const Icon(Icons.lock_outline, size: 16, color: AppColors.statusSuccess), Icon(Icons.lock_outline, size: 16, color: AppColors.statusSuccess),
const SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Status pembayaran diverifikasi otomatis oleh Midtrans. Tekan "Saya Sudah Bayar" setelah selesai.', 'Status pembayaran diverifikasi otomatis oleh Midtrans. Tekan "Saya Sudah Bayar" setelah selesai.',
style: const TextStyle(fontSize: 11, color: AppColors.textMuted, height: 1.4), style: TextStyle(fontSize: 11, color: AppColors.textMuted, height: 1.4),
), ),
), ),
], ],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment