1064 lines
35 KiB
Dart
1064 lines
35 KiB
Dart
import 'dart:io';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../providers/dashboard_providers.dart';
|
|
import '../../../core/constants/app_constants.dart';
|
|
import '../../../core/services/vehicle_service.dart';
|
|
import '../../../widgets/status_badge.dart';
|
|
import '../../../widgets/error_view.dart';
|
|
import 'vehicle_form_screen.dart';
|
|
|
|
class VehicleDetailScreen extends ConsumerWidget {
|
|
final String id;
|
|
|
|
const VehicleDetailScreen({super.key, required this.id});
|
|
|
|
String _resolveUrl(String photo) {
|
|
if (!photo.startsWith('http')) {
|
|
return '${AppConstants.baseUrl.replaceAll('/api/v1', '')}$photo';
|
|
}
|
|
if (Platform.isAndroid) {
|
|
return photo.replaceFirst('localhost', '10.0.2.2');
|
|
}
|
|
return photo;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final vehicleAsync = ref.watch(vehicleDetailProvider(id));
|
|
|
|
return vehicleAsync.when(
|
|
loading: () => const Scaffold(
|
|
body: Center(child: CircularProgressIndicator()),
|
|
),
|
|
error: (e, _) => Scaffold(
|
|
appBar: AppBar(),
|
|
body: ErrorView(
|
|
message: 'Could not load vehicle',
|
|
onRetry: () => ref.invalidate(vehicleDetailProvider(id)),
|
|
),
|
|
),
|
|
data: (vehicle) {
|
|
return DefaultTabController(
|
|
length: 3,
|
|
child: Scaffold(
|
|
body: NestedScrollView(
|
|
headerSliverBuilder: (context, _) => [
|
|
SliverAppBar(
|
|
expandedHeight: 240,
|
|
pinned: true,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.add_photo_alternate_outlined),
|
|
onPressed: () async {
|
|
final result = await showModalBottomSheet<bool>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => VehiclePhotosSheet(
|
|
vehicleId: id,
|
|
currentPhotos: vehicle.photos
|
|
.map((p) => _resolveUrl(p))
|
|
.toList(),
|
|
),
|
|
);
|
|
if (result == true) {
|
|
ref.invalidate(vehicleDetailProvider(id));
|
|
}
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.edit_outlined),
|
|
onPressed: () async {
|
|
final result = await Navigator.of(context).push<bool>(
|
|
MaterialPageRoute(
|
|
builder: (_) =>
|
|
VehicleFormScreen(vehicle: vehicle)),
|
|
);
|
|
if (result == true) {
|
|
ref.invalidate(vehicleDetailProvider(id));
|
|
}
|
|
},
|
|
),
|
|
PopupMenuButton<String>(
|
|
onSelected: (v) async {
|
|
if (v == 'delete') {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => AlertDialog(
|
|
title: const Text('Delete Vehicle'),
|
|
content: Text(
|
|
'Delete ${vehicle.displayName}? This cannot be undone.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () =>
|
|
Navigator.pop(context, false),
|
|
child: const Text('Cancel')),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor:
|
|
const Color(0xFFE02424)),
|
|
onPressed: () =>
|
|
Navigator.pop(context, true),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed == true && context.mounted) {
|
|
await ref
|
|
.read(vehicleServiceProvider)
|
|
.deleteVehicle(id);
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
}
|
|
}
|
|
},
|
|
itemBuilder: (_) => const [
|
|
PopupMenuItem(
|
|
value: 'delete',
|
|
child: Text('Delete',
|
|
style: TextStyle(color: Color(0xFFE02424))),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
background: vehicle.primaryPhoto != null
|
|
? CachedNetworkImage(
|
|
imageUrl: _resolveUrl(vehicle.primaryPhoto!),
|
|
fit: BoxFit.cover,
|
|
errorWidget: (context, url, _) => _placeholder(),
|
|
)
|
|
: _placeholder(),
|
|
),
|
|
),
|
|
SliverPersistentHeader(
|
|
pinned: true,
|
|
delegate: _TabBarDelegate(
|
|
TabBar(
|
|
tabs: const [
|
|
Tab(text: 'Details'),
|
|
Tab(text: 'Calendar'),
|
|
Tab(text: 'Maintenance'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
body: TabBarView(
|
|
children: [
|
|
_DetailsTab(
|
|
vehicle: vehicle,
|
|
onPublishChanged: (v) async {
|
|
await ref
|
|
.read(vehicleServiceProvider)
|
|
.togglePublish(id, v);
|
|
ref.invalidate(vehicleDetailProvider(id));
|
|
},
|
|
),
|
|
_CalendarTab(vehicleId: id),
|
|
_MaintenanceTab(vehicleId: id),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _placeholder() => Container(
|
|
color: const Color(0xFFF3F4F6),
|
|
child: const Icon(Icons.directions_car,
|
|
size: 72, color: Color(0xFFD1D5DB)),
|
|
);
|
|
}
|
|
|
|
class _TabBarDelegate extends SliverPersistentHeaderDelegate {
|
|
final TabBar tabBar;
|
|
|
|
_TabBarDelegate(this.tabBar);
|
|
|
|
@override
|
|
double get minExtent => tabBar.preferredSize.height;
|
|
@override
|
|
double get maxExtent => tabBar.preferredSize.height;
|
|
|
|
@override
|
|
Widget build(
|
|
BuildContext context, double shrinkOffset, bool overlapsContent) =>
|
|
Container(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
child: tabBar,
|
|
);
|
|
|
|
@override
|
|
bool shouldRebuild(_TabBarDelegate old) => false;
|
|
}
|
|
|
|
class _DetailsTab extends StatelessWidget {
|
|
final dynamic vehicle;
|
|
final ValueChanged<bool> onPublishChanged;
|
|
|
|
const _DetailsTab({
|
|
required this.vehicle,
|
|
required this.onPublishChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView(
|
|
padding: const EdgeInsets.all(20),
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
vehicle.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xFF111928),
|
|
),
|
|
),
|
|
),
|
|
StatusBadge(status: vehicle.status),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
vehicle.licensePlate,
|
|
style:
|
|
const TextStyle(fontSize: 15, color: Color(0xFF6B7280)),
|
|
),
|
|
const SizedBox(height: 20),
|
|
_InfoGrid(vehicle: vehicle),
|
|
const SizedBox(height: 20),
|
|
const Text(
|
|
'Pricing',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xFF111928),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.attach_money, color: Color(0xFF1A56DB)),
|
|
const SizedBox(width: 8),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'\$${(vehicle.dailyRate / 100).toStringAsFixed(2)}/day',
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xFF1A56DB),
|
|
),
|
|
),
|
|
const Text(
|
|
'Daily rate',
|
|
style: TextStyle(
|
|
fontSize: 12, color: Color(0xFF6B7280)),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
Switch(
|
|
value: vehicle.isPublished,
|
|
onChanged: onPublishChanged,
|
|
),
|
|
const Text(
|
|
'Published',
|
|
style: TextStyle(
|
|
fontSize: 12, color: Color(0xFF6B7280)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MaintenanceTab extends ConsumerWidget {
|
|
final String vehicleId;
|
|
|
|
const _MaintenanceTab({required this.vehicleId});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final async = ref.watch(maintenanceProvider(vehicleId));
|
|
|
|
return async.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => ErrorView(
|
|
message: 'Could not load maintenance logs',
|
|
onRetry: () => ref.invalidate(maintenanceProvider(vehicleId)),
|
|
),
|
|
data: (logs) => Stack(
|
|
children: [
|
|
logs.isEmpty
|
|
? const Center(
|
|
child: Text('No maintenance records',
|
|
style: TextStyle(color: Color(0xFF9CA3AF))),
|
|
)
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: logs.length,
|
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
|
itemBuilder: (_, i) => _MaintenanceCard(log: logs[i]),
|
|
),
|
|
Positioned(
|
|
bottom: 16,
|
|
right: 16,
|
|
child: FloatingActionButton.small(
|
|
onPressed: () =>
|
|
_showAddMaintenance(context, ref, vehicleId),
|
|
child: const Icon(Icons.add),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showAddMaintenance(
|
|
BuildContext context, WidgetRef ref, String vehicleId) {
|
|
final typeCtrl = TextEditingController();
|
|
final descCtrl = TextEditingController();
|
|
final costCtrl = TextEditingController();
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(context).viewInsets.bottom),
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Add Maintenance Log',
|
|
style: TextStyle(
|
|
fontSize: 18, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: typeCtrl,
|
|
decoration:
|
|
const InputDecoration(labelText: 'Type (e.g. Oil Change)')),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: descCtrl,
|
|
decoration:
|
|
const InputDecoration(labelText: 'Description'),
|
|
maxLines: 2),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: costCtrl,
|
|
decoration:
|
|
const InputDecoration(labelText: 'Cost (\$)'),
|
|
keyboardType: TextInputType.number),
|
|
const SizedBox(height: 20),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
if (typeCtrl.text.isEmpty) return;
|
|
await ref.read(vehicleServiceProvider).addMaintenance(
|
|
vehicleId,
|
|
{
|
|
'type': typeCtrl.text.trim(),
|
|
if (descCtrl.text.isNotEmpty)
|
|
'description': descCtrl.text.trim(),
|
|
if (costCtrl.text.isNotEmpty)
|
|
'cost': ((double.tryParse(costCtrl.text) ?? 0) *
|
|
100)
|
|
.round(),
|
|
'performedAt': DateTime.now().toIso8601String(),
|
|
},
|
|
);
|
|
ref.invalidate(maintenanceProvider(vehicleId));
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: const Size.fromHeight(48)),
|
|
child: const Text('Add Log'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MaintenanceCard extends StatelessWidget {
|
|
final dynamic log;
|
|
|
|
const _MaintenanceCard({required this.log});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fmt = DateFormat('MMM d, yyyy');
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
log.type as String,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
if (log.cost != null)
|
|
Text(
|
|
'\$${((log.cost as int) / 100).toStringAsFixed(2)}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF1A56DB)),
|
|
),
|
|
],
|
|
),
|
|
if (log.description != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
log.description as String,
|
|
style: const TextStyle(
|
|
fontSize: 13, color: Color(0xFF6B7280)),
|
|
),
|
|
],
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
fmt.format(log.performedAt as DateTime),
|
|
style:
|
|
const TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoGrid extends StatelessWidget {
|
|
final dynamic vehicle;
|
|
|
|
const _InfoGrid({required this.vehicle});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final items = [
|
|
('Category', vehicle.category),
|
|
if (vehicle.seats != null) ('Seats', '${vehicle.seats}'),
|
|
if (vehicle.transmission != null)
|
|
('Transmission', vehicle.transmission!),
|
|
if (vehicle.fuelType != null) ('Fuel', vehicle.fuelType!),
|
|
if (vehicle.mileage != null) ('Mileage', '${vehicle.mileage} km'),
|
|
];
|
|
|
|
return Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: items
|
|
.map((item) => Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF3F4F6),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.$1,
|
|
style: const TextStyle(
|
|
fontSize: 11, color: Color(0xFF9CA3AF)),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.$2,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF111928),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
))
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Calendar Tab ─────────────────────────────────────────────────────────────
|
|
|
|
class _CalendarTab extends ConsumerStatefulWidget {
|
|
final String vehicleId;
|
|
|
|
const _CalendarTab({required this.vehicleId});
|
|
|
|
@override
|
|
ConsumerState<_CalendarTab> createState() => _CalendarTabState();
|
|
}
|
|
|
|
class _CalendarTabState extends ConsumerState<_CalendarTab>
|
|
with AutomaticKeepAliveClientMixin {
|
|
late int _year;
|
|
late int _month;
|
|
DateTime? _selectedDay;
|
|
|
|
static const _eventColors = {
|
|
'RESERVATION': Color(0xFF1A56DB),
|
|
'MAINTENANCE': Color(0xFFFF6B00),
|
|
'BLOCK': Color(0xFFE02424),
|
|
};
|
|
|
|
static const _eventBg = {
|
|
'RESERVATION': Color(0xFFEBF5FF),
|
|
'MAINTENANCE': Color(0xFFFFF3E0),
|
|
'BLOCK': Color(0xFFFDE8E8),
|
|
};
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final now = DateTime.now();
|
|
_year = now.year;
|
|
_month = now.month;
|
|
}
|
|
|
|
void _prevMonth() => setState(() {
|
|
if (_month == 1) {
|
|
_month = 12;
|
|
_year--;
|
|
} else {
|
|
_month--;
|
|
}
|
|
_selectedDay = null;
|
|
});
|
|
|
|
void _nextMonth() => setState(() {
|
|
if (_month == 12) {
|
|
_month = 1;
|
|
_year++;
|
|
} else {
|
|
_month++;
|
|
}
|
|
_selectedDay = null;
|
|
});
|
|
|
|
bool _isSameDay(DateTime a, DateTime b) =>
|
|
a.year == b.year && a.month == b.month && a.day == b.day;
|
|
|
|
List<CalendarEvent> _eventsForDay(
|
|
DateTime day, List<CalendarEvent> events) {
|
|
final d = DateTime(day.year, day.month, day.day);
|
|
return events.where((e) {
|
|
final s = DateTime(e.startDate.year, e.startDate.month, e.startDate.day);
|
|
final en = DateTime(e.endDate.year, e.endDate.month, e.endDate.day);
|
|
return !d.isBefore(s) && !d.isAfter(en);
|
|
}).toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
final key = (vehicleId: widget.vehicleId, year: _year, month: _month);
|
|
final eventsAsync = ref.watch(calendarProvider(key));
|
|
|
|
return Stack(
|
|
children: [
|
|
eventsAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => ErrorView(
|
|
message: 'Could not load calendar',
|
|
onRetry: () => ref.invalidate(calendarProvider(key)),
|
|
),
|
|
data: (events) => ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 80),
|
|
children: [
|
|
// Month navigation
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.chevron_left),
|
|
onPressed: _prevMonth,
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
DateFormat('MMMM yyyy').format(DateTime(_year, _month)),
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xFF111928),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.chevron_right),
|
|
onPressed: _nextMonth,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
// Day-of-week headers
|
|
Row(
|
|
children: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
|
.map((d) => Expanded(
|
|
child: Center(
|
|
child: Text(d,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF9CA3AF),
|
|
)),
|
|
),
|
|
))
|
|
.toList(),
|
|
),
|
|
const SizedBox(height: 4),
|
|
// Calendar grid
|
|
_buildGrid(events),
|
|
const SizedBox(height: 12),
|
|
// Legend
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: _eventColors.entries.expand((entry) => [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: entry.value,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
entry.key[0] + entry.key.substring(1).toLowerCase(),
|
|
style: const TextStyle(
|
|
fontSize: 11, color: Color(0xFF6B7280)),
|
|
),
|
|
const SizedBox(width: 14),
|
|
]).toList(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Selected-day events
|
|
if (_selectedDay != null) ...[
|
|
Text(
|
|
DateFormat('EEEE, MMMM d').format(_selectedDay!),
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
color: Color(0xFF374151),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
..._eventsForDay(_selectedDay!, events).map((e) => _EventTile(
|
|
event: e,
|
|
color: _eventColors[e.type] ?? Colors.grey,
|
|
bg: _eventBg[e.type] ?? const Color(0xFFF3F4F6),
|
|
onDelete: e.type == 'BLOCK'
|
|
? () async {
|
|
await ref
|
|
.read(vehicleServiceProvider)
|
|
.deleteCalendarBlock(
|
|
widget.vehicleId, e.id);
|
|
ref.invalidate(calendarProvider(key));
|
|
}
|
|
: null,
|
|
)),
|
|
if (_eventsForDay(_selectedDay!, events).isEmpty)
|
|
const Text(
|
|
'No events on this day',
|
|
style: TextStyle(color: Color(0xFF9CA3AF)),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 16,
|
|
right: 16,
|
|
child: FloatingActionButton.small(
|
|
heroTag: 'cal_add',
|
|
tooltip: 'Block dates',
|
|
onPressed: () => _showAddBlock(context),
|
|
child: const Icon(Icons.block),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildGrid(List<CalendarEvent> events) {
|
|
final daysInMonth = DateTime(_year, _month + 1, 0).day;
|
|
final firstDay = DateTime(_year, _month, 1);
|
|
final startOffset = (firstDay.weekday - 1) % 7;
|
|
|
|
final cells = <Widget>[
|
|
for (int i = 0; i < startOffset; i++) const SizedBox(),
|
|
for (int day = 1; day <= daysInMonth; day++)
|
|
_buildDayCell(day, events),
|
|
];
|
|
|
|
while (cells.length % 7 != 0) {
|
|
cells.add(const SizedBox());
|
|
}
|
|
|
|
return GridView.count(
|
|
crossAxisCount: 7,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
children: cells,
|
|
);
|
|
}
|
|
|
|
Widget _buildDayCell(int day, List<CalendarEvent> events) {
|
|
final date = DateTime(_year, _month, day);
|
|
final dayEvents = _eventsForDay(date, events);
|
|
final isSelected =
|
|
_selectedDay != null && _isSameDay(_selectedDay!, date);
|
|
final isToday = _isSameDay(DateTime.now(), date);
|
|
|
|
return GestureDetector(
|
|
onTap: () => setState(
|
|
() => _selectedDay = isSelected ? null : date),
|
|
child: Container(
|
|
margin: const EdgeInsets.all(2),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? const Color(0xFF1A56DB)
|
|
: isToday
|
|
? const Color(0xFFEBF5FF)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: isToday && !isSelected
|
|
? Border.all(color: const Color(0xFF1A56DB))
|
|
: null,
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'$day',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: isSelected
|
|
? Colors.white
|
|
: isToday
|
|
? const Color(0xFF1A56DB)
|
|
: const Color(0xFF374151),
|
|
),
|
|
),
|
|
if (dayEvents.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: dayEvents
|
|
.take(3)
|
|
.map((e) => Container(
|
|
width: 4,
|
|
height: 4,
|
|
margin:
|
|
const EdgeInsets.symmetric(horizontal: 1),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? Colors.white70
|
|
: (_eventColors[e.type] ??
|
|
Colors.grey),
|
|
shape: BoxShape.circle,
|
|
),
|
|
))
|
|
.toList(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showAddBlock(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => _AddBlockSheet(
|
|
vehicleId: widget.vehicleId,
|
|
initialStart: _selectedDay ?? DateTime.now(),
|
|
onSaved: () {
|
|
final key =
|
|
(vehicleId: widget.vehicleId, year: _year, month: _month);
|
|
ref.invalidate(calendarProvider(key));
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EventTile extends StatelessWidget {
|
|
final CalendarEvent event;
|
|
final Color color;
|
|
final Color bg;
|
|
final VoidCallback? onDelete;
|
|
|
|
const _EventTile({
|
|
required this.event,
|
|
required this.color,
|
|
required this.bg,
|
|
this.onDelete,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fmt = DateFormat('MMM d');
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 4,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
event.label,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
Text(
|
|
'${fmt.format(event.startDate)} → ${fmt.format(event.endDate)}',
|
|
style: const TextStyle(
|
|
fontSize: 11, color: Color(0xFF6B7280)),
|
|
),
|
|
if (event.status != null)
|
|
Text(
|
|
event.status!,
|
|
style: const TextStyle(
|
|
fontSize: 11, color: Color(0xFF9CA3AF)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (onDelete != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
color: const Color(0xFFE02424),
|
|
onPressed: onDelete,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AddBlockSheet extends ConsumerStatefulWidget {
|
|
final String vehicleId;
|
|
final DateTime initialStart;
|
|
final VoidCallback onSaved;
|
|
|
|
const _AddBlockSheet({
|
|
required this.vehicleId,
|
|
required this.initialStart,
|
|
required this.onSaved,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<_AddBlockSheet> createState() => _AddBlockSheetState();
|
|
}
|
|
|
|
class _AddBlockSheetState extends ConsumerState<_AddBlockSheet> {
|
|
late DateTime _start;
|
|
late DateTime _end;
|
|
final _reasonCtrl = TextEditingController();
|
|
String _blockType = 'MANUAL';
|
|
bool _saving = false;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_start = widget.initialStart;
|
|
_end = widget.initialStart.add(const Duration(days: 1));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_reasonCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
setState(() {
|
|
_saving = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
await ref.read(vehicleServiceProvider).createCalendarBlock(
|
|
widget.vehicleId,
|
|
{
|
|
'startDate':
|
|
'${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z',
|
|
'endDate':
|
|
'${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z',
|
|
'type': _blockType,
|
|
if (_reasonCtrl.text.trim().isNotEmpty)
|
|
'reason': _reasonCtrl.text.trim(),
|
|
},
|
|
);
|
|
widget.onSaved();
|
|
if (mounted) Navigator.of(context).pop();
|
|
} catch (e) {
|
|
setState(() {
|
|
_saving = false;
|
|
_error = 'Failed to create block. Please try again.';
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fmt = DateFormat('MMM d, yyyy');
|
|
final far = DateTime.now().add(const Duration(days: 365 * 2));
|
|
final near = DateTime.now().subtract(const Duration(days: 365));
|
|
|
|
return Padding(
|
|
padding:
|
|
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Block Dates',
|
|
style:
|
|
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.calendar_today, size: 14),
|
|
label: Text(fmt.format(_start)),
|
|
onPressed: () async {
|
|
final d = await showDatePicker(
|
|
context: context,
|
|
initialDate: _start,
|
|
firstDate: near,
|
|
lastDate: far,
|
|
);
|
|
if (d != null) {
|
|
setState(() {
|
|
_start = d;
|
|
if (_end.isBefore(_start)) _end = _start;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
|
child: Text('→'),
|
|
),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.calendar_today, size: 14),
|
|
label: Text(fmt.format(_end)),
|
|
onPressed: () async {
|
|
final d = await showDatePicker(
|
|
context: context,
|
|
initialDate: _end,
|
|
firstDate: _start,
|
|
lastDate: far,
|
|
);
|
|
if (d != null) setState(() => _end = d);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
InputDecorator(
|
|
decoration: const InputDecoration(labelText: 'Block Type'),
|
|
child: DropdownButton<String>(
|
|
value: _blockType,
|
|
isExpanded: true,
|
|
underline: const SizedBox(),
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'MANUAL', child: Text('Manual Block')),
|
|
DropdownMenuItem(
|
|
value: 'MAINTENANCE', child: Text('Maintenance')),
|
|
],
|
|
onChanged: (v) => setState(() => _blockType = v!),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _reasonCtrl,
|
|
decoration:
|
|
const InputDecoration(labelText: 'Reason (optional)'),
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 10),
|
|
Text(_error!,
|
|
style: const TextStyle(color: Color(0xFF9B1C1C))),
|
|
],
|
|
const SizedBox(height: 20),
|
|
ElevatedButton(
|
|
onPressed: _saving ? null : _save,
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: const Size.fromHeight(48)),
|
|
child: _saving
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor:
|
|
AlwaysStoppedAnimation<Color>(Colors.white)),
|
|
)
|
|
: const Text('Block Dates'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|