654 lines
18 KiB
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|