initial codex commit
This commit is contained in:
@@ -1,41 +1,700 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:mileograph_flutter/services/dataService.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class TractionPage extends StatelessWidget {
|
||||
class TractionPage extends StatefulWidget {
|
||||
const TractionPage({
|
||||
super.key,
|
||||
this.selectionMode = false,
|
||||
this.onSelect,
|
||||
this.selectedKeys = const {},
|
||||
});
|
||||
|
||||
final bool selectionMode;
|
||||
final ValueChanged<LocoSummary>? onSelect;
|
||||
final Set<String> selectedKeys;
|
||||
|
||||
@override
|
||||
State<TractionPage> createState() => _TractionPageState();
|
||||
}
|
||||
|
||||
class _TractionPageState extends State<TractionPage> {
|
||||
final _classController = TextEditingController();
|
||||
final _numberController = TextEditingController();
|
||||
bool _hadOnly = true;
|
||||
bool _initialised = false;
|
||||
bool _showAdvancedFilters = false;
|
||||
String? _selectedClass;
|
||||
late Set<String> _selectedKeys;
|
||||
final _nameController = TextEditingController();
|
||||
final _operatorController = TextEditingController();
|
||||
final _statusController = TextEditingController();
|
||||
final _evnController = TextEditingController();
|
||||
final _ownerController = TextEditingController();
|
||||
final _locationController = TextEditingController();
|
||||
final _liveryController = TextEditingController();
|
||||
final _domainController = TextEditingController();
|
||||
final _typeController = TextEditingController();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_initialised) {
|
||||
_initialised = true;
|
||||
_selectedKeys = {...widget.selectedKeys};
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<DataService>().fetchClassList();
|
||||
_refreshTraction();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_classController.dispose();
|
||||
_numberController.dispose();
|
||||
_nameController.dispose();
|
||||
_operatorController.dispose();
|
||||
_statusController.dispose();
|
||||
_evnController.dispose();
|
||||
_ownerController.dispose();
|
||||
_locationController.dispose();
|
||||
_liveryController.dispose();
|
||||
_domainController.dispose();
|
||||
_typeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _hasFilters {
|
||||
return [
|
||||
_selectedClass,
|
||||
_classController.text,
|
||||
_numberController.text,
|
||||
_nameController.text,
|
||||
_operatorController.text,
|
||||
_statusController.text,
|
||||
_evnController.text,
|
||||
_ownerController.text,
|
||||
_locationController.text,
|
||||
_liveryController.text,
|
||||
_domainController.text,
|
||||
_typeController.text,
|
||||
].any((value) => (value ?? '').toString().trim().isNotEmpty);
|
||||
}
|
||||
|
||||
Future<void> _refreshTraction({bool append = false}) async {
|
||||
final data = context.read<DataService>();
|
||||
final filters = {
|
||||
"name": _nameController.text.trim(),
|
||||
"operator": _operatorController.text.trim(),
|
||||
"status": _statusController.text.trim(),
|
||||
"evn": _evnController.text.trim(),
|
||||
"owner": _ownerController.text.trim(),
|
||||
"location": _locationController.text.trim(),
|
||||
"livery": _liveryController.text.trim(),
|
||||
"domain": _domainController.text.trim(),
|
||||
"type": _typeController.text.trim(),
|
||||
}..removeWhere((key, value) => value.isEmpty);
|
||||
await data.fetchTraction(
|
||||
hadOnly: _hadOnly && !_hasFilters,
|
||||
locoClass: _selectedClass ?? _classController.text.trim(),
|
||||
locoNumber: _numberController.text.trim(),
|
||||
offset: append ? data.traction.length : 0,
|
||||
append: append,
|
||||
filters: filters,
|
||||
);
|
||||
}
|
||||
|
||||
void _clearFilters() {
|
||||
for (final controller in [
|
||||
_classController,
|
||||
_numberController,
|
||||
_nameController,
|
||||
_operatorController,
|
||||
_statusController,
|
||||
_evnController,
|
||||
_ownerController,
|
||||
_locationController,
|
||||
_liveryController,
|
||||
_domainController,
|
||||
_typeController,
|
||||
]) {
|
||||
controller.clear();
|
||||
}
|
||||
setState(() {
|
||||
_selectedClass = null;
|
||||
});
|
||||
setState(() {
|
||||
_hadOnly = true;
|
||||
});
|
||||
_refreshTraction();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = context.watch<DataService>();
|
||||
return ListView.builder(
|
||||
itemCount: data.traction.length,
|
||||
itemBuilder: (context, index) {
|
||||
final loco = data.traction[index];
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [Text('${loco.locoClass} ${loco.number}')],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
final traction = data.traction;
|
||||
final classOptions = data.locoClasses;
|
||||
final isMobile = MediaQuery.of(context).size.width < 700;
|
||||
|
||||
final listView = RefreshIndicator(
|
||||
onRefresh: _refreshTraction,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Fleet',
|
||||
style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 2),
|
||||
Text('Traction',
|
||||
style: Theme.of(context).textTheme.headlineSmall),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _refreshTraction,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Filters',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
TextButton(
|
||||
onPressed: _clearFilters,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 240,
|
||||
child: Autocomplete<String>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
final query = textEditingValue.text.toLowerCase();
|
||||
if (query.isEmpty) {
|
||||
return classOptions;
|
||||
}
|
||||
return classOptions.where(
|
||||
(c) => c.toLowerCase().contains(query),
|
||||
);
|
||||
},
|
||||
initialValue:
|
||||
TextEditingValue(text: _classController.text),
|
||||
fieldViewBuilder: (context, controller, focusNode,
|
||||
onFieldSubmitted) {
|
||||
controller.value = _classController.value;
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Class',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (val) {
|
||||
_classController.text = val;
|
||||
},
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
);
|
||||
},
|
||||
onSelected: (String selection) {
|
||||
setState(() {
|
||||
_selectedClass = selection;
|
||||
_classController.text = selection;
|
||||
});
|
||||
_refreshTraction();
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _numberController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Number',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Had only'),
|
||||
selected: _hadOnly,
|
||||
onSelected: (v) {
|
||||
setState(() => _hadOnly = v);
|
||||
_refreshTraction();
|
||||
},
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(
|
||||
() => _showAdvancedFilters = !_showAdvancedFilters),
|
||||
icon: Icon(_showAdvancedFilters
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more),
|
||||
label: Text(_showAdvancedFilters
|
||||
? 'Hide filters'
|
||||
: 'More filters'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _refreshTraction,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Search'),
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
crossFadeState: _showAdvancedFilters
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _operatorController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Operator',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _statusController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Status',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _evnController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'EVN',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _ownerController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Owner',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _locationController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Location',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _liveryController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Livery',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _domainController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Domain',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: isMobile ? double.infinity : 220,
|
||||
child: TextField(
|
||||
controller: _typeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _refreshTraction(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (data.isTractionLoading && traction.isEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (traction.isEmpty)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${loco.name}',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
'No traction found',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text('${loco.mileage} mi'),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Try relaxing the filters or sync again.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
...traction.map((loco) => _buildTractionCard(context, loco)),
|
||||
if (data.tractionHasMore || data.isTractionLoading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: data.isTractionLoading
|
||||
? null
|
||||
: () => _refreshTraction(append: true),
|
||||
icon: data.isTractionLoading
|
||||
? const SizedBox(
|
||||
height: 14,
|
||||
width: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.expand_more),
|
||||
label: Text(
|
||||
data.isTractionLoading ? 'Loading...' : 'Load more',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.selectionMode) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leadingWidth: 140,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('Back'),
|
||||
style: TextButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: null,
|
||||
),
|
||||
body: listView,
|
||||
);
|
||||
}
|
||||
|
||||
return listView;
|
||||
}
|
||||
|
||||
Widget _buildTractionCard(BuildContext context, LocoSummary loco) {
|
||||
final keyVal = '${loco.locoClass}-${loco.number}';
|
||||
final isSelected = _selectedKeys.contains(keyVal);
|
||||
final status = loco.status ?? 'Unknown';
|
||||
final operatorName = loco.operator ?? '';
|
||||
final domain = loco.domain ?? '';
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${loco.locoClass} ${loco.number}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
if ((loco.name ?? '').isNotEmpty)
|
||||
Text(
|
||||
loco.name ?? '',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
),
|
||||
Chip(
|
||||
label: Text(status),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _showLocoInfo(loco),
|
||||
icon: const Icon(Icons.info_outline),
|
||||
label: const Text('Details'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.selectionMode)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
if (widget.onSelect != null) {
|
||||
widget.onSelect!(loco);
|
||||
}
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedKeys.remove(keyVal);
|
||||
} else {
|
||||
_selectedKeys.add(keyVal);
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: Icon(isSelected
|
||||
? Icons.remove_circle_outline
|
||||
: Icons.add_circle_outline),
|
||||
label: Text(isSelected ? 'Remove' : 'Add to entry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_statPill(
|
||||
context,
|
||||
label: 'Miles',
|
||||
value: _formatNumber(loco.mileage),
|
||||
),
|
||||
_statPill(
|
||||
context,
|
||||
label: 'Trips',
|
||||
value: (loco.trips ?? loco.journeys ?? 0).toString(),
|
||||
),
|
||||
if (operatorName.isNotEmpty)
|
||||
_statPill(
|
||||
context,
|
||||
label: 'Operator',
|
||||
value: operatorName,
|
||||
),
|
||||
if (domain.isNotEmpty)
|
||||
_statPill(
|
||||
context,
|
||||
label: 'Domain',
|
||||
value: domain,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statPill(BuildContext context,
|
||||
{required String label, required String value}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$label: ',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showLocoInfo(LocoSummary loco) async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
maxChildSize: 0.9,
|
||||
initialChildSize: 0.65,
|
||||
builder: (_, controller) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${loco.locoClass} ${loco.number}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
if ((loco.name ?? '').isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 52.0, bottom: 12),
|
||||
child: Text(
|
||||
loco.name ?? '',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: controller,
|
||||
children: [
|
||||
_detailRow('Status', loco.status ?? 'Unknown'),
|
||||
_detailRow('Operator', loco.operator ?? ''),
|
||||
_detailRow('Domain', loco.domain ?? ''),
|
||||
_detailRow('Owner', loco.owner ?? ''),
|
||||
_detailRow('Livery', loco.livery ?? ''),
|
||||
_detailRow('Location', loco.location ?? ''),
|
||||
_detailRow(
|
||||
'Mileage', _formatNumber(loco.mileage ?? 0)),
|
||||
_detailRow('Trips',
|
||||
(loco.trips ?? loco.journeys ?? 0).toString()),
|
||||
_detailRow('EVN', loco.evn ?? ''),
|
||||
if (loco.notes != null && loco.notes!.isNotEmpty)
|
||||
_detailRow('Notes', loco.notes!),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRow(String label, String value) {
|
||||
if (value.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatNumber(double? value) {
|
||||
if (value == null) return '0';
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user