TKK_E32221292/Aplikasi Android/lib/features/activity/activity_page.dart

654 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_application_1/app/app_session_view_model.dart';
import 'package:flutter_application_1/core/theme/app_colors.dart';
import 'package:flutter_application_1/features/activity/ride_session_model.dart';
import 'package:flutter_application_1/shared/dialogs/confirmation_dialog.dart';
import 'package:flutter_application_1/shared/dialogs/session_name_dialog.dart';
import 'package:provider/provider.dart';
String _formatRideDistance(double distanceKm) {
final meters = distanceKm * 1000.0;
if (meters < 1000.0) {
if (meters < 10.0) {
return '${meters.toStringAsFixed(2)} m';
}
if (meters < 100.0) {
return '${meters.toStringAsFixed(1)} m';
}
return '${meters.toStringAsFixed(0)} m';
}
return '${distanceKm.toStringAsFixed(2)} km';
}
class ActivityPage extends StatefulWidget {
const ActivityPage({super.key});
@override
State<ActivityPage> createState() => _ActivityPageState();
}
class _ActivityPageState extends State<ActivityPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
context.read<AppSessionViewModel>().loadActivityData();
});
}
@override
Widget build(BuildContext context) {
final session = context.watch<AppSessionViewModel>();
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Activity',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
color: AppColors.textDark,
),
),
const SizedBox(height: 20),
if (session.activityErrorMessage != null) ...[
_ActivityErrorBanner(
message: session.activityErrorMessage!,
onDismiss: session.clearActivityError,
),
const SizedBox(height: 16),
],
session.isRecordingActive
? _ActiveRecordingCard(
durationText: _formatDuration(session.activeRecordingDuration),
distanceText: _formatRideDistance(session.recordingDistanceKm),
onStop: session.isActivityBusy
? null
: () => _showSessionNameDialog(context, session),
)
: _StartRideCard(
onStart: session.isActivityBusy
? null
: () => session.startRideRecording(),
),
if (!session.isRecordingActive) ...[
const SizedBox(height: 16),
_SummaryCards(
totalDistanceKm: session.totalDistanceKmRaw,
totalRides: session.totalRecordings,
),
],
const SizedBox(height: 22),
Row(
children: [
const Expanded(
child: Text(
'Recent Sessions',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textDark,
),
),
),
if (!session.isRecordingActive)
TextButton(
onPressed: session.isActivityBusy
? null
: () => session.loadActivityData(force: true),
child: const Text('Refresh'),
),
],
),
const SizedBox(height: 12),
if (session.isActivityBusy && session.recentRideSessions.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: CircularProgressIndicator(),
),
)
else if (session.recentRideSessions.isEmpty)
const _EmptySessionsState()
else
...session.recentRideSessions.map(
(item) => _SessionHistoryItem(
item: item,
onDelete: session.isActivityBusy
? null
: () => _confirmDeleteSession(context, session, item),
),
),
],
),
),
),
);
}
Future<void> _showSessionNameDialog(
BuildContext context,
AppSessionViewModel session,
) async {
await session.setBlockingDialogVisible(true);
if (!context.mounted) {
return;
}
final sessionName = await SessionNameDialog.show(context);
if (!context.mounted) {
return;
}
await session.setBlockingDialogVisible(false);
if (!context.mounted) {
return;
}
if (sessionName == null) {
return;
}
final normalizedName = sessionName.trim().isEmpty
? 'Recording Session'
: sessionName.trim();
await session.stopRideRecordingAndSave(sessionName: normalizedName);
}
Future<void> _confirmDeleteSession(
BuildContext context,
AppSessionViewModel session,
RideSessionModel item,
) async {
await session.setBlockingDialogVisible(true);
if (!context.mounted) {
return;
}
final shouldDelete = await ConfirmationDialog.show(
context,
title: 'Hapus session?',
message:
'Session "${item.title}" akan dihapus dari riwayat perjalanan Anda.',
confirmText: 'Hapus',
cancelText: 'Batal',
isDestructive: true,
);
if (!context.mounted) {
return;
}
await session.setBlockingDialogVisible(false);
if (!context.mounted) {
return;
}
if (!shouldDelete) {
return;
}
await session.deleteRideSession(item);
}
static String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
class _ActivityErrorBanner extends StatelessWidget {
final String message;
final VoidCallback onDismiss;
const _ActivityErrorBanner({
required this.message,
required this.onDismiss,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFFF1F2),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFFECDD3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: AppColors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: AppColors.textDark,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: onDismiss,
icon: const Icon(Icons.close, color: AppColors.textMuted),
),
],
),
);
}
}
class _StartRideCard extends StatelessWidget {
final VoidCallback? onStart;
const _StartRideCard({required this.onStart});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primaryBlue, AppColors.primaryBlueDark],
),
borderRadius: BorderRadius.circular(24),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Start Recording',
style: TextStyle(
color: AppColors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
const Text(
'Mulai recording kapan saja untuk menyimpan jarak dan durasi session yang sedang berjalan.',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
const SizedBox(height: 18),
ElevatedButton(
onPressed: onStart,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.white,
foregroundColor: AppColors.primaryBlue,
elevation: 0,
minimumSize: const Size.fromHeight(54),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: const Text(
'START RECORDING',
style: TextStyle(fontWeight: FontWeight.w800),
),
),
],
),
);
}
}
class _ActiveRecordingCard extends StatelessWidget {
final String durationText;
final String distanceText;
final VoidCallback? onStop;
const _ActiveRecordingCard({
required this.durationText,
required this.distanceText,
required this.onStop,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFDDE7FF)),
boxShadow: const [
BoxShadow(
color: AppColors.shadow,
blurRadius: 16,
offset: Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: AppColors.primaryBlue,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
const Text(
'Recording Active',
style: TextStyle(
color: AppColors.textDark,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _RecordingMetric(
label: 'Active Time',
value: durationText,
),
),
const SizedBox(width: 12),
Expanded(
child: _RecordingMetric(
label: 'Active Distance',
value: distanceText,
),
),
],
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onStop,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.white,
foregroundColor: AppColors.primaryBlue,
elevation: 0,
side: const BorderSide(color: AppColors.inputBorder),
minimumSize: const Size.fromHeight(54),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: const Text(
'STOP RECORDING',
style: TextStyle(fontWeight: FontWeight.w800),
),
),
),
],
),
);
}
}
class _RecordingMetric extends StatelessWidget {
final String label;
final String value;
const _RecordingMetric({
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightBlue,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textMuted,
),
),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: AppColors.textDark,
),
),
],
),
);
}
}
class _SummaryCards extends StatelessWidget {
final double totalDistanceKm;
final int totalRides;
const _SummaryCards({
required this.totalDistanceKm,
required this.totalRides,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _SummaryCard(
title: 'Total Distance',
value: _formatRideDistance(totalDistanceKm),
icon: Icons.route,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
title: 'Total Sessions',
value: '$totalRides',
icon: Icons.history,
),
),
],
);
}
}
class _SummaryCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
const _SummaryCard({
required this.title,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.lightBlue,
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: AppColors.primaryBlue),
const SizedBox(height: 10),
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w700,
color: AppColors.textMuted,
),
),
const SizedBox(height: 6),
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: AppColors.textDark,
),
),
],
),
);
}
}
class _SessionHistoryItem extends StatelessWidget {
final RideSessionModel item;
final VoidCallback? onDelete;
const _SessionHistoryItem({
required this.item,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.inputBorder),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: const TextStyle(
fontWeight: FontWeight.w800,
color: AppColors.textDark,
),
),
const SizedBox(height: 6),
Text(
_formatDate(item.startedAt),
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatRideDistance(item.distanceKmRaw),
style: const TextStyle(
fontWeight: FontWeight.w800,
color: AppColors.textDark,
),
),
const SizedBox(height: 6),
Text(
_formatHistoryDuration(item.durationSec),
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
],
),
const SizedBox(width: 10),
IconButton(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
static String _formatDate(DateTime date) {
final year = date.year.toString().padLeft(4, '0');
final month = date.month.toString().padLeft(2, '0');
final day = date.day.toString().padLeft(2, '0');
return '$year-$month-$day';
}
static String _formatHistoryDuration(int durationSec) {
final duration = Duration(seconds: durationSec);
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes.toString().padLeft(2, '0')}m';
}
return '${minutes.toString().padLeft(2, '0')}m ${seconds.toString().padLeft(2, '0')}s';
}
}
class _EmptySessionsState extends StatelessWidget {
const _EmptySessionsState();
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.inputBorder),
),
child: const Text(
'Belum ada recording session yang tersimpan.',
style: TextStyle(
color: AppColors.textMuted,
fontWeight: FontWeight.w600,
),
),
);
}
}