Release version 1.0.0
This commit is contained in:
371
lib/screens/add_service_screen.dart
Normal file
371
lib/screens/add_service_screen.dart
Normal file
@@ -0,0 +1,371 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/service.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import '../services/session_manager.dart';
|
||||
|
||||
enum _ServicePerformer { shop, self }
|
||||
|
||||
class AddServiceScreen extends StatefulWidget {
|
||||
final ServiceRecord? service;
|
||||
final VoidCallback? onSaved;
|
||||
final bool standalone;
|
||||
|
||||
AddServiceScreen({this.service, this.onSaved, this.standalone = false});
|
||||
|
||||
@override
|
||||
_AddServiceScreenState createState() => _AddServiceScreenState();
|
||||
}
|
||||
|
||||
class _AddServiceScreenState extends State<AddServiceScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
String? _selectedVehicleId;
|
||||
ServiceType? _selectedType;
|
||||
final TextEditingController _customTypeController = TextEditingController();
|
||||
final TextEditingController _itemNameController = TextEditingController();
|
||||
final TextEditingController _costController = TextEditingController();
|
||||
final TextEditingController _mileageController = TextEditingController();
|
||||
final TextEditingController _shopController = TextEditingController();
|
||||
_ServicePerformer _performer = _ServicePerformer.shop;
|
||||
final TextEditingController _noteController = TextEditingController();
|
||||
final List<Uint8List> _photos = [];
|
||||
DateTime? _selectedDate;
|
||||
final TextEditingController _dateController = TextEditingController();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_initialized) return;
|
||||
final session = Provider.of<SessionManager>(context);
|
||||
if (widget.service != null) {
|
||||
final s = widget.service!;
|
||||
_selectedVehicleId = s.vehicleId;
|
||||
_selectedType = s.serviceType;
|
||||
if (s.customType != null) _customTypeController.text = s.customType!;
|
||||
if (s.itemName != null) _itemNameController.text = s.itemName!;
|
||||
_costController.text = s.cost.toString();
|
||||
_mileageController.text = s.mileage.toString();
|
||||
if (s.selfService) {
|
||||
_performer = _ServicePerformer.self;
|
||||
} else {
|
||||
_performer = _ServicePerformer.shop;
|
||||
if (s.shop != null) _shopController.text = s.shop!;
|
||||
}
|
||||
if (s.note != null) _noteController.text = s.note!;
|
||||
for (final p in s.photos) {
|
||||
try {
|
||||
final data = p.contains(',') ? p.split(',').last : p;
|
||||
_photos.add(base64Decode(data));
|
||||
} catch (_) {}
|
||||
}
|
||||
_selectedDate = s.date;
|
||||
if (s.date != null) {
|
||||
_dateController.text = _formatDate(s.date!);
|
||||
}
|
||||
} else {
|
||||
_selectedVehicleId = session.defaultVehicle?.id;
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final d = date.toLocal();
|
||||
return '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = Provider.of<SessionManager>(context);
|
||||
final vehicles = session.vehicles;
|
||||
|
||||
final form = Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: InputDecoration(labelText: 'Vehicle'),
|
||||
value: _selectedVehicleId,
|
||||
items: vehicles
|
||||
.map((vehicle) => DropdownMenuItem(
|
||||
value: vehicle.id,
|
||||
child: Text(vehicle.name),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _selectedVehicleId = value),
|
||||
validator: (value) =>
|
||||
value == null ? 'Please select a vehicle' : null,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
DropdownButtonFormField<ServiceType>(
|
||||
decoration: InputDecoration(labelText: 'Service Type'),
|
||||
value: _selectedType,
|
||||
items: ServiceType.values
|
||||
.map((t) => DropdownMenuItem(
|
||||
value: t,
|
||||
child: Text(t.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _selectedType = value),
|
||||
validator: (value) =>
|
||||
value == null ? 'Please select a service type' : null,
|
||||
),
|
||||
if (_selectedType == ServiceType.other) ...[
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _customTypeController,
|
||||
decoration: InputDecoration(labelText: 'Custom Type'),
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Enter custom type' : null,
|
||||
),
|
||||
],
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _itemNameController,
|
||||
decoration: InputDecoration(labelText: 'Item Name'),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _costController,
|
||||
decoration: InputDecoration(labelText: 'Cost'),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: _numberValidator,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _mileageController,
|
||||
decoration: InputDecoration(labelText: 'Mileage'),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: _numberValidator,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
RadioListTile<_ServicePerformer>(
|
||||
value: _ServicePerformer.shop,
|
||||
groupValue: _performer,
|
||||
title: Text('Performed at shop'),
|
||||
onChanged: (v) => setState(() => _performer = v!),
|
||||
),
|
||||
RadioListTile<_ServicePerformer>(
|
||||
value: _ServicePerformer.self,
|
||||
groupValue: _performer,
|
||||
title: Text('Self Service'),
|
||||
onChanged: (v) => setState(() {
|
||||
_performer = v!;
|
||||
_shopController.clear();
|
||||
}),
|
||||
),
|
||||
if (_performer == _ServicePerformer.shop) ...[
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _shopController,
|
||||
decoration: InputDecoration(labelText: 'Shop'),
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Enter shop name' : null,
|
||||
),
|
||||
],
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _noteController,
|
||||
decoration: InputDecoration(labelText: 'Note'),
|
||||
),
|
||||
if (_photos.isNotEmpty) ...[
|
||||
SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(_photos.length, (i) {
|
||||
return Stack(
|
||||
children: [
|
||||
Image.memory(
|
||||
_photos[i],
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _photos.removeAt(i)),
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
child: Icon(Icons.close, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: _pickPhoto,
|
||||
icon: Icon(Icons.add_a_photo),
|
||||
label: Text('Add Photo'),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _dateController,
|
||||
decoration: InputDecoration(labelText: 'Date'),
|
||||
readOnly: true,
|
||||
onTap: () async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate ?? now,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_selectedDate = picked;
|
||||
_dateController.text = _formatDate(picked);
|
||||
});
|
||||
}
|
||||
},
|
||||
validator: (v) => _selectedDate == null ? 'Select a date' : null,
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _submitForm,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
|
||||
child: Text(widget.service == null
|
||||
? 'Create Service Record'
|
||||
: 'Update Service Record'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.standalone) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.service == null
|
||||
? 'Add Service Record'
|
||||
: 'Edit Service Record'),
|
||||
),
|
||||
body: form,
|
||||
);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
Future<void> _pickPhoto() async {
|
||||
final picker = ImagePicker();
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.camera_alt),
|
||||
title: Text('Take Photo'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.photo_library),
|
||||
title: Text('Choose from Gallery'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (source == null) return;
|
||||
final file = await picker.pickImage(source: source);
|
||||
if (file == null) return;
|
||||
var bytes = await file.readAsBytes();
|
||||
final decoded = img.decodeImage(bytes);
|
||||
if (decoded != null) {
|
||||
const maxDim = 800;
|
||||
img.Image resized;
|
||||
if (decoded.width > decoded.height) {
|
||||
resized = img.copyResize(decoded, width: maxDim);
|
||||
} else {
|
||||
resized = img.copyResize(decoded, height: maxDim);
|
||||
}
|
||||
bytes = Uint8List.fromList(img.encodeJpg(resized, quality: 70));
|
||||
}
|
||||
setState(() => _photos.add(bytes));
|
||||
}
|
||||
|
||||
String? _numberValidator(String? value) {
|
||||
if (value == null || value.isEmpty) return 'This field cannot be empty';
|
||||
if (double.tryParse(value) == null) return 'Enter a valid number';
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _submitForm() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
final session = Provider.of<SessionManager>(context, listen: false);
|
||||
final service = ServiceRecord(
|
||||
id: widget.service?.id,
|
||||
vehicleId: _selectedVehicleId!,
|
||||
serviceType: _selectedType!,
|
||||
customType: _selectedType == ServiceType.other
|
||||
? _customTypeController.text
|
||||
: null,
|
||||
itemName: _itemNameController.text.isEmpty
|
||||
? null
|
||||
: _itemNameController.text,
|
||||
cost: double.parse(_costController.text),
|
||||
mileage: int.parse(_mileageController.text),
|
||||
shop: _performer == _ServicePerformer.shop ? _shopController.text : null,
|
||||
selfService: _performer == _ServicePerformer.self,
|
||||
note: _noteController.text.isEmpty ? null : _noteController.text,
|
||||
photos: _photos.map((b) => base64Encode(b)).toList(),
|
||||
date: _selectedDate,
|
||||
);
|
||||
|
||||
bool success = false;
|
||||
if (widget.service == null) {
|
||||
success = await session.addService(service);
|
||||
} else if (widget.service!.id != null) {
|
||||
success = await session.updateService(widget.service!.id!, service);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? 'Service record saved successfully!'
|
||||
: 'Failed to save service record.')),
|
||||
);
|
||||
|
||||
if (success) {
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
widget.onSaved?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_customTypeController.dispose();
|
||||
_itemNameController.dispose();
|
||||
_costController.dispose();
|
||||
_mileageController.dispose();
|
||||
_shopController.dispose();
|
||||
_noteController.dispose();
|
||||
_dateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user