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 {
}
}
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)
static Future<ApiResponse<Map<String, dynamic>>> getMe() async {
try {
......@@ -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)
static Future<ApiResponse<List<dynamic>>> getPengcabs() async {
try {
......
This diff is collapsed.
......@@ -5,6 +5,7 @@ import '../../core/services/storage_service.dart';
import '../../providers/app_state.dart';
import '../main_navigation_screen.dart';
import 'register_screen.dart';
import 'forgot_password_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
......@@ -106,9 +107,9 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
const SizedBox(height: 10),
Text(
const Text(
'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> {
),
ElevatedButton(
onPressed: () async {
await StorageService.setApiBaseUrl(controller.text.trim());
if (ctx.mounted) Navigator.pop(ctx);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Server diset ke: ${controller.text.trim()}')),
);
}
final val = controller.text.trim();
final messenger = ScaffoldMessenger.of(context);
final nav = Navigator.of(ctx);
await StorageService.setApiBaseUrl(val);
nav.pop();
messenger.showSnackBar(
SnackBar(content: Text('Server diset ke: $val')),
);
},
style: ElevatedButton.styleFrom(backgroundColor: AppColors.primary),
child: const Text('Simpan'),
......@@ -285,8 +287,9 @@ class _LoginScreenState extends State<LoginScreen> {
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fitur reset password via OTP dapat diakses melalui admin/bantuan.')),
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ForgotPasswordScreen()),
);
},
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';
import '../../core/services/api_service.dart';
import '../../core/services/storage_service.dart';
import '../../models/user_model.dart';
import 'edit_profile_screen.dart';
import '../kta/kta_digital_screen.dart';
import '../ticketing/my_tickets_screen.dart';
import '../splash/welcome_screen.dart';
......@@ -219,6 +220,25 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
child: Column(
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(
icon: Icons.credit_card,
title: 'KTA Digital & QR Pass',
......
......@@ -170,14 +170,14 @@ class _PaymentInstructionScreenState extends State<PaymentInstructionScreen> {
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.borderSubtle),
),
child: Row(
child: const Row(
children: [
const Icon(Icons.lock_outline, size: 16, color: AppColors.statusSuccess),
const SizedBox(width: 8),
Icon(Icons.lock_outline, size: 16, color: AppColors.statusSuccess),
SizedBox(width: 8),
Expanded(
child: Text(
'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